diff --git a/examples/56-add-items-validation.js b/examples/56-add-items-validation.js new file mode 100644 index 000000000..c234bf4b5 --- /dev/null +++ b/examples/56-add-items-validation.js @@ -0,0 +1,51 @@ +module.exports = function (migration) { + migration.createContentType('contentModelA', { + name: 'Content Model A', + description: 'A content model for addItemsValidation' + }) + + migration.createContentType('contentModelB', { + name: 'Content Model B', + description: 'B content model for addItemsValidation' + }) + + migration.createContentType('contentModelC', { + name: 'Content Model C', + description: 'C content model for addItemsValidation' + }) + + const testModel = migration.createContentType('testModel', { + name: 'Test Model', + description: 'A test model for addItemsValidation' + }) + + testModel.createField('name').name('Name').type('Symbol').required(true) + + testModel + .createField('tags') + .name('Tags') + .type('Array') + .items({ + type: 'Symbol' + }) + .addItemsValidation([{ unique: true }, { size: { min: 1, max: 5 } }]) + + testModel + .createField('skills') + .name('Skills') + .type('Array') + .items({ + type: 'Symbol' + }) + .addItemsValidation([{ size: { min: 1 } }]) + + testModel + .createField('relatedEntries') + .name('Related Entries') + .type('Array') + .items({ + type: 'Link', + linkType: 'Entry' + }) + .addItemsValidation([{ linkContentType: ['contentModelA', 'contentModelB'] }]) +} diff --git a/examples/57-add-items-validation-update.js b/examples/57-add-items-validation-update.js new file mode 100644 index 000000000..fa5a10715 --- /dev/null +++ b/examples/57-add-items-validation-update.js @@ -0,0 +1,6 @@ +module.exports = function (migration) { + const testModel = migration.editContentType('testModel') + + // Update the relatedEntries's items validations using addItemsValidation to add contentModelC + testModel.editField('relatedEntries').addItemsValidation([{ linkContentType: ['contentModelC'] }]) +} diff --git a/src/bin/cli.ts b/src/bin/cli.ts index c843802a4..23565b8f4 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -270,6 +270,6 @@ const createRun = ({ shouldThrow }) => console.warn(chalk`⚠️ {bold.yellow Migration aborted}`) } } - export const runMigration = createRun({ shouldThrow: true }) export default createRun({ shouldThrow: false }) + diff --git a/src/lib/action/field-add-items-validation.ts b/src/lib/action/field-add-items-validation.ts new file mode 100644 index 000000000..81db05ab6 --- /dev/null +++ b/src/lib/action/field-add-items-validation.ts @@ -0,0 +1,60 @@ +import { FieldAction } from './field-action' +import ContentType from '../entities/content-type' + +class FieldAddItemsValidationAction extends FieldAction { + protected validations: any[] + + constructor(contentTypeId: string, fieldId: string, validations: any[]) { + super(contentTypeId, fieldId) + this.validations = validations + } + + async applyTo(ct: ContentType) { + const fields = ct.fields + const field = fields.getField(this.getFieldId()) + + if (!field.items) { + field.items = { type: 'Symbol' } // Default type for array items + } + + if (!field.items.validations) { + field.items.validations = [] + } + + // Merge new validations with existing ones + const existingValidations = field.items.validations || [] + const newValidations = this.validations || [] + + // Handle linkContentType validations specially + const existingLinkContentType = existingValidations.find((v) => v.linkContentType) + const newLinkContentType = newValidations.find((v) => v.linkContentType) + + if (existingLinkContentType && newLinkContentType) { + // Merge linkContentType arrays + const mergedContentTypes = [ + ...new Set([ + ...(existingLinkContentType.linkContentType || []), + ...(newLinkContentType.linkContentType || []) + ]) + ] + + // Remove both old validations + const filteredExisting = existingValidations.filter((v) => !v.linkContentType) + const filteredNew = newValidations.filter((v) => !v.linkContentType) + + // Add merged validation + field.items.validations = [ + ...filteredExisting, + ...filteredNew, + { linkContentType: mergedContentTypes } + ] + } else { + // If no linkContentType to merge, just append + field.items.validations = [...existingValidations, ...newValidations] + } + + fields.setField(this.getFieldId(), field) + } +} + +export { FieldAddItemsValidationAction } diff --git a/src/lib/intent/base-intent.ts b/src/lib/intent/base-intent.ts index ab5482cad..4e81a1d76 100644 --- a/src/lib/intent/base-intent.ts +++ b/src/lib/intent/base-intent.ts @@ -94,6 +94,10 @@ export default abstract class Intent implements IntentInterface { return false } + isFieldAddItemsValidation() { + return false + } + isContentTransform() { return false } diff --git a/src/lib/intent/field-add-items-validation.ts b/src/lib/intent/field-add-items-validation.ts new file mode 100644 index 000000000..4368e23ee --- /dev/null +++ b/src/lib/intent/field-add-items-validation.ts @@ -0,0 +1,56 @@ +import Intent from './base-intent' +import { FieldAddItemsValidationAction } from '../action/field-add-items-validation' +import { PlanMessage } from '../interfaces/plan-message' +import chalk from 'chalk' +import { RawStep } from '../interfaces/raw-step' + +export default class FieldAddItemsValidationIntent extends Intent { + constructor(rawStep: RawStep) { + super(rawStep) + } + + isFieldAddItemsValidation() { + return true + } + + groupsWith(other: Intent): boolean { + const sameContentType = other.getContentTypeId() === this.getContentTypeId() + return ( + (other.isContentTypeUpdate() || + other.isContentTypeCreate() || + other.isContentTypeAnnotate() || + other.isFieldCreate() || + other.isFieldUpdate() || + other.isFieldMove() || + other.isFieldAddItemsValidation()) && + sameContentType + ) + } + + endsGroup(): boolean { + return false + } + + toActions() { + return [ + new FieldAddItemsValidationAction( + this.getContentTypeId(), + this.getFieldId(), + this.payload.props.validations + ) + ] + } + + toPlanMessage(): PlanMessage { + return { + heading: chalk`Update Content Type {bold.yellow ${this.getContentTypeId()}}`, + sections: [ + { + heading: chalk`Add items validations to array field {yellow ${this.getFieldId()}}`, + details: [`Validations: ${JSON.stringify(this.payload.props.validations)}`] + } + ], + details: [] + } + } +} \ No newline at end of file diff --git a/src/lib/intent/index.ts b/src/lib/intent/index.ts index c0c25f608..c0b94cb9f 100644 --- a/src/lib/intent/index.ts +++ b/src/lib/intent/index.ts @@ -35,6 +35,7 @@ import EntrySetTagsIntent from './entry-set-tags' import EditorLayoutChangeFieldGroupIdIntent from './editor-layout/editor-layout-change-field-group-id' import EditorLayoutDeleteIntent from './editor-layout/editor-layout-delete' import FieldAnnotateIntent from './field-annotate' +import FieldAddItemsValidationIntent from './field-add-items-validation' export { Intent as default, @@ -74,5 +75,6 @@ export { EntrySetTagsIntent as EntrySetTags, EditorLayoutMoveFieldIntent as EditorLayoutMoveField, EditorLayoutChangeFieldGroupIdIntent as EditorLayoutChangeFieldGroupId, - EditorLayoutDeleteIntent as EditorLayoutDelete + EditorLayoutDeleteIntent as EditorLayoutDelete, + FieldAddItemsValidationIntent as FieldAddItemsValidation } diff --git a/src/lib/migration-steps/action-creators.ts b/src/lib/migration-steps/action-creators.ts index 9ddac4ff3..b8b573060 100644 --- a/src/lib/migration-steps/action-creators.ts +++ b/src/lib/migration-steps/action-creators.ts @@ -693,6 +693,60 @@ const actionCreators = { annotations: annotationIds, fieldAnnotationPayload } + }), + addAnnotation: ( + contentTypeId, + contentTypeInstanceId, + fieldId, + fieldInstanceId, + callsite, + annotationIds, + fieldAnnotationPayload + ): Intents.FieldAnnotate => + new Intents.FieldAnnotate({ + type: 'field/annotate', + meta: { + contentTypeInstanceId: `contentType/${contentTypeId}/${contentTypeInstanceId}`, + fieldInstanceId: `fields/${fieldId}/${fieldInstanceId}`, + callsite: { + file: callsite?.getFileName(), + line: callsite?.getLineNumber() + } + }, + payload: { + contentTypeId, + fieldId, + props: { + annotations: annotationIds, + fieldAnnotationPayload + } + } + }), + addItemsValidation: ( + contentTypeId, + contentTypeInstanceId, + fieldId, + fieldInstanceId, + callsite, + validations + ): Intents.FieldAddItemsValidation => + new Intents.FieldAddItemsValidation({ + type: 'field/addItemsValidation', + meta: { + contentTypeInstanceId: `contentType/${contentTypeId}/${contentTypeInstanceId}`, + fieldInstanceId: `fields/${fieldId}/${fieldInstanceId}`, + callsite: { + file: callsite?.getFileName(), + line: callsite?.getLineNumber() + } + }, + payload: { + contentTypeId, + fieldId, + props: { + validations + } + } }) }, tag: { diff --git a/src/lib/migration-steps/index.ts b/src/lib/migration-steps/index.ts index fa8a0c34a..e98a0f5d1 100644 --- a/src/lib/migration-steps/index.ts +++ b/src/lib/migration-steps/index.ts @@ -85,6 +85,41 @@ class Field extends DispatchProxy { ) return this } + + addAnnotation(annotationIds: string[], fieldAnnotationPayload?: Record) { + const callsite = getFirstExternalCaller() + const fieldInstanceId = this.contentType.fieldInstanceIds.getNew(this.id) + this.contentType.dispatch( + actionCreators.field.addAnnotation( + this.contentType.id, + this.contentType.instanceId, + this.id, + fieldInstanceId, + callsite, + annotationIds, + fieldAnnotationPayload + ) + ) + + return this + } + + addItemsValidation(validations: any[]) { + const callsite = getFirstExternalCaller() + const fieldInstanceId = this.contentType.fieldInstanceIds.getNew(this.id) + this.contentType.dispatch( + actionCreators.field.addItemsValidation( + this.contentType.id, + this.contentType.instanceId, + this.id, + fieldInstanceId, + callsite, + validations + ) + ) + + return this + } } class EditorLayout extends DispatchProxy { diff --git a/test/integration/migration.spec.js b/test/integration/migration.spec.js index 5db55f806..35e7a710c 100644 --- a/test/integration/migration.spec.js +++ b/test/integration/migration.spec.js @@ -43,6 +43,8 @@ const deleteFieldOnContentTypeWithEditorLayout = require('../../examples/52-dele const renameFieldOnContentTypeWithEditorLayout = require('../../examples/53-rename-field-in-content-type-with-editor-layout') const createRichTextFieldWithValidation = require('../../examples/22-create-rich-text-field-with-validation') const createExperienceType = require('../../examples/54-create-experience-type') +const addItemsValidation = require('../../examples/56-add-items-validation') +const addItemsValidationUpdate = require('../../examples/57-add-items-validation-update') const { createMigrationParser } = require('../../built/lib/migration-parser') const { DEFAULT_SIDEBAR_LIST } = require('../../built/lib/action/sidebarwidget') @@ -1402,4 +1404,67 @@ describe('the migration', function () { } }) }) + + it('creates content type with items validation', async function () { + await migrator(addItemsValidation) + const ct = await request({ + method: 'GET', + url: '/content_types/testModel' + }) + + // Verify the content type was created with all fields + expect(ct.name).to.eql('Test Model') + expect(ct.description).to.eql('A test model for addItemsValidation') + + // Verify the name field + const nameField = ct.fields.find((f) => f.id === 'name') + // eslint-disable-next-line no-unused-expressions + expect(nameField).to.exist + expect(nameField.type).to.eql('Symbol') + expect(nameField.required).to.eql(true) + + // Verify the tags field with its validations + const tagsField = ct.fields.find((f) => f.id === 'tags') + // eslint-disable-next-line no-unused-expressions + expect(tagsField).to.exist + expect(tagsField.type).to.eql('Array') + expect(tagsField.items.type).to.eql('Symbol') + expect(tagsField.items.validations).to.deep.include({ unique: true }) + expect(tagsField.items.validations).to.deep.include({ size: { min: 1, max: 5 } }) + + // Verify the skills field with its validations + const skillsField = ct.fields.find((f) => f.id === 'skills') + // eslint-disable-next-line no-unused-expressions + expect(skillsField).to.exist + expect(skillsField.type).to.eql('Array') + expect(skillsField.items.type).to.eql('Symbol') + expect(skillsField.items.validations).to.deep.include({ size: { min: 1 } }) + + // Verify the relatedEntries field with its validations + const relatedEntriesField = ct.fields.find((f) => f.id === 'relatedEntries') + // eslint-disable-next-line no-unused-expressions + expect(relatedEntriesField).to.exist + expect(relatedEntriesField.type).to.eql('Array') + expect(relatedEntriesField.items.type).to.eql('Link') + expect(relatedEntriesField.items.linkType).to.eql('Entry') + expect(relatedEntriesField.items.validations).to.deep.include({ + linkContentType: ['contentModelA', 'contentModelB'] + }) + }) + + it('updates items validation for existing field', async function () { + await migrator(addItemsValidationUpdate) + const ct = await request({ + method: 'GET', + url: '/content_types/testModel' + }) + + // Verify the relatedEntries field now includes contentModelC in its validations + const relatedEntriesField = ct.fields.find((f) => f.id === 'relatedEntries') + // eslint-disable-next-line no-unused-expressions + expect(relatedEntriesField).to.exist + expect(relatedEntriesField.items.validations).to.deep.include({ + linkContentType: ['contentModelC'] + }) + }) }) diff --git a/test/unit/lib/actions/field-add-items-validation.spec.ts b/test/unit/lib/actions/field-add-items-validation.spec.ts new file mode 100644 index 000000000..e51635611 --- /dev/null +++ b/test/unit/lib/actions/field-add-items-validation.spec.ts @@ -0,0 +1,95 @@ +import { expect } from 'chai' +import { FieldAddItemsValidationAction } from '../../../../src/lib/action/field-add-items-validation' +import ContentType from '../../../../src/lib/entities/content-type' +import { APIContentType } from '../../../../src/lib/interfaces/content-type' + +describe('FieldAddItemsValidationAction', () => { + let contentType: ContentType + let action: FieldAddItemsValidationAction + + beforeEach(() => { + const apiContentType: APIContentType = { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array', + items: { + type: 'Symbol', + validations: [{ size: { min: 1 } }] + } + } + ] + } + contentType = new ContentType(apiContentType) + }) + + describe('applyTo', () => { + it('adds validations to existing items validations', async () => { + action = new FieldAddItemsValidationAction('test', 'test', [{ unique: true }]) + await action.applyTo(contentType) + + const field = contentType.fields.getField('test') + expect(field.items?.validations).to.have.length(2) + expect(field.items?.validations?.[0]).to.deep.equal({ size: { min: 1 } }) + expect(field.items?.validations?.[1]).to.deep.equal({ unique: true }) + }) + + it('creates items validations array if it does not exist', async () => { + const apiContentType: APIContentType = { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array', + items: { + type: 'Symbol' + } + } + ] + } + contentType = new ContentType(apiContentType) + + action = new FieldAddItemsValidationAction('test', 'test', [{ unique: true }]) + await action.applyTo(contentType) + + const field = contentType.fields.getField('test') + expect(field.items?.validations).to.have.length(1) + expect(field.items?.validations?.[0]).to.deep.equal({ unique: true }) + }) + + it('creates items object if it does not exist', async () => { + const apiContentType: APIContentType = { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array' + } + ] + } + contentType = new ContentType(apiContentType) + + action = new FieldAddItemsValidationAction('test', 'test', [{ unique: true }]) + await action.applyTo(contentType) + + const field = contentType.fields.getField('test') + expect(field.items).to.exist + expect(field.items?.type).to.equal('Symbol') + expect(field.items?.validations).to.have.length(1) + expect(field.items?.validations?.[0]).to.deep.equal({ unique: true }) + }) + }) +}) \ No newline at end of file diff --git a/test/unit/lib/intent/field-add-items-validation.spec.ts b/test/unit/lib/intent/field-add-items-validation.spec.ts new file mode 100644 index 000000000..8ad85824f --- /dev/null +++ b/test/unit/lib/intent/field-add-items-validation.spec.ts @@ -0,0 +1,123 @@ +import actionCreators from '../../../../src/lib/migration-steps/action-creators' +import FieldAddItemsValidationIntent from '../../../../src/lib/intent/field-add-items-validation' +import { expect } from 'chai' +import runIntent from './run-intent' +import fakeCallsite from '../../../helpers/fake-callsite' +import makeApiContentType from '../../../helpers/make-api-content-type' +import { APIContentType } from '../../../../src/lib/interfaces/content-type' + +describe('FieldAddItemsValidationIntent', function () { + describe('when adding validations to array field items', function () { + it('adds validations to existing items validations', async function () { + const intent: FieldAddItemsValidationIntent = actionCreators.field.addItemsValidation( + 'test', + 0, + 'test', + 0, + [{ unique: true }], + fakeCallsite() + ) + + const contentTypes: APIContentType[] = [ + { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array', + items: { + type: 'Symbol', + validations: [{ size: { min: 1 } }] + } + } + ] + } + ] + + const api = await runIntent(intent, contentTypes, []) + + const contentType = await api.getContentType('test') + const field = contentType.fields.getField('test') + expect(field.items?.validations).to.have.length(2) + expect(field.items?.validations?.[0]).to.deep.equal({ size: { min: 1 } }) + expect(field.items?.validations?.[1]).to.deep.equal({ unique: true }) + }) + + it('creates items validations array if it does not exist', async function () { + const intent: FieldAddItemsValidationIntent = actionCreators.field.addItemsValidation( + 'test', + 0, + 'test', + 0, + [{ unique: true }], + fakeCallsite() + ) + + const contentTypes: APIContentType[] = [ + { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array', + items: { + type: 'Symbol' + } + } + ] + } + ] + + const api = await runIntent(intent, contentTypes, []) + + const contentType = await api.getContentType('test') + const field = contentType.fields.getField('test') + expect(field.items?.validations).to.have.length(1) + expect(field.items?.validations?.[0]).to.deep.equal({ unique: true }) + }) + + it('creates items object if it does not exist', async function () { + const intent: FieldAddItemsValidationIntent = actionCreators.field.addItemsValidation( + 'test', + 0, + 'test', + 0, + [{ unique: true }], + fakeCallsite() + ) + + const contentTypes: APIContentType[] = [ + { + sys: { + id: 'test', + version: 1 + }, + name: 'Test', + fields: [ + { + id: 'test', + type: 'Array' + } + ] + } + ] + + const api = await runIntent(intent, contentTypes, []) + + const contentType = await api.getContentType('test') + const field = contentType.fields.getField('test') + expect(field.items).to.exist + expect(field.items?.type).to.equal('Symbol') + expect(field.items?.validations).to.have.length(1) + expect(field.items?.validations?.[0]).to.deep.equal({ unique: true }) + }) + }) +}) \ No newline at end of file