From 5b01770aff025cf0305cd392f59b49486a9688b1 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 9 Apr 2024 12:05:50 +0200 Subject: [PATCH] feat(api-headless-cms): add content entry traverser (#4072) --- .../contentTraverser/mocks/article.entry.ts | 27 + .../contentTraverser/mocks/article.model.ts | 165 +++ .../contentTraverser/mocks/richTextValue.ts | 275 ++++ .../contentTraverser/modelAst.test.ts | 247 ++++ .../contentTraverser/traverser.test.ts | 50 + .../src/crud/contentEntry.crud.ts | 13 + .../contentEntry/referenceFieldsMapping.ts | 4 +- .../src/crud/contentModel.crud.ts | 16 + .../dynamicZone/dynamicZoneField.ts | 21 + .../src/graphqlFields/object.ts | 17 +- .../api-headless-cms/src/types/context.ts | 233 ++++ .../src/types/fields/dynamicZoneField.ts | 25 + .../src/types/fields/objectField.ts | 14 + .../api-headless-cms/src/types/identity.ts | 19 + packages/api-headless-cms/src/types/index.ts | 10 + packages/api-headless-cms/src/types/model.ts | 207 +++ .../api-headless-cms/src/types/modelAst.ts | 48 + .../api-headless-cms/src/types/modelField.ts | 343 +++++ .../api-headless-cms/src/types/modelGroup.ts | 79 ++ .../api-headless-cms/src/types/plugins.ts | 366 +++++ .../api-headless-cms/src/{ => types}/types.ts | 1232 +---------------- .../ContentEntryTraverser.ts | 126 ++ .../CmsModelFieldToAstConverterFromPlugins.ts | 21 + .../CmsModelFieldToAstFromPlugin.ts | 22 + .../contentModelAst/CmsModelToAstConverter.ts | 24 + .../src/utils/contentModelAst/index.ts | 3 + 26 files changed, 2393 insertions(+), 1214 deletions(-) create mode 100644 packages/api-headless-cms/__tests__/contentTraverser/mocks/article.entry.ts create mode 100644 packages/api-headless-cms/__tests__/contentTraverser/mocks/article.model.ts create mode 100644 packages/api-headless-cms/__tests__/contentTraverser/mocks/richTextValue.ts create mode 100644 packages/api-headless-cms/__tests__/contentTraverser/modelAst.test.ts create mode 100644 packages/api-headless-cms/__tests__/contentTraverser/traverser.test.ts create mode 100644 packages/api-headless-cms/src/types/context.ts create mode 100644 packages/api-headless-cms/src/types/fields/dynamicZoneField.ts create mode 100644 packages/api-headless-cms/src/types/fields/objectField.ts create mode 100644 packages/api-headless-cms/src/types/identity.ts create mode 100644 packages/api-headless-cms/src/types/index.ts create mode 100644 packages/api-headless-cms/src/types/model.ts create mode 100644 packages/api-headless-cms/src/types/modelAst.ts create mode 100644 packages/api-headless-cms/src/types/modelField.ts create mode 100644 packages/api-headless-cms/src/types/modelGroup.ts create mode 100644 packages/api-headless-cms/src/types/plugins.ts rename packages/api-headless-cms/src/{ => types}/types.ts (61%) create mode 100644 packages/api-headless-cms/src/utils/contentEntryTraverser/ContentEntryTraverser.ts create mode 100644 packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstConverterFromPlugins.ts create mode 100644 packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstFromPlugin.ts create mode 100644 packages/api-headless-cms/src/utils/contentModelAst/CmsModelToAstConverter.ts create mode 100644 packages/api-headless-cms/src/utils/contentModelAst/index.ts diff --git a/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.entry.ts b/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.entry.ts new file mode 100644 index 00000000000..928fca58cf1 --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.entry.ts @@ -0,0 +1,27 @@ +import { richTextValue } from "./richTextValue"; + +export const articleEntry = { + title: "Article #1", + body: richTextValue, + categories: [{ modelId: "category", entryId: "12345678" }], + content: [ + { + title: "Hero #1", + _templateId: "cv2zf965v324ivdc7e1vt" + }, + { + _templateId: "9ht43gurhegkbdfsaafyads", + settings: { + title: "Title", + seo: [ + { + title: "title-0" + }, + { + title: "title-1" + } + ] + } + } + ] +}; diff --git a/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.model.ts b/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.model.ts new file mode 100644 index 00000000000..dc97af09efa --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentTraverser/mocks/article.model.ts @@ -0,0 +1,165 @@ +import { createPrivateModel } from "~/plugins"; + +export const articleModel = createPrivateModel({ + titleFieldId: "title", + name: "Article", + modelId: "article", + fields: [ + { + id: "title", + multipleValues: false, + label: "Title", + type: "text", + storageId: "text@title", + fieldId: "title" + }, + { + id: "body", + multipleValues: false, + label: "Body", + type: "rich-text", + storageId: "rich-text@body", + fieldId: "body" + }, + { + id: "categories", + multipleValues: true, + label: "Categories", + type: "ref", + storageId: "ref@categories", + fieldId: "categories", + settings: { + models: [{ modelId: "category" }] + } + }, + { + id: "content", + fieldId: "content", + storageId: "dynamicZone@content", + type: "dynamicZone", + label: "Content", + multipleValues: true, + settings: { + templates: [ + { + name: "Hero #1", + gqlTypeName: "Hero", + icon: "fas/flag", + description: "The top piece of content on every page.", + id: "cv2zf965v324ivdc7e1vt", + fields: [ + { + id: "title", + fieldId: "title", + label: "Title", + type: "text" + } + ] + }, + { + name: "Simple Text #1", + gqlTypeName: "SimpleText", + icon: "fas/file-text", + description: "Simple paragraph of text.", + id: "81qiz2v453wx9uque0gox", + fields: [ + { + id: "text", + fieldId: "text", + label: "Text", + type: "long-text" + } + ] + }, + { + name: "Settings", + gqlTypeName: "Settings", + icon: "fas/file-text", + description: "Settings", + id: "9ht43gurhegkbdfsaafyads", + fields: [ + { + id: "settings", + fieldId: "settings", + label: "Settings", + type: "object", + settings: { + fields: [ + { + id: "title", + fieldId: "title", + type: "text", + label: "Title" + }, + { + id: "seo", + fieldId: "seo", + type: "object", + label: "SEO", + multipleValues: true, + settings: { + fields: [ + { + id: "title", + fieldId: "title", + type: "text", + label: "Title" + } + ] + } + } + ] + } + }, + { + type: "dynamicZone", + settings: { + templates: [ + { + name: "Ad", + gqlTypeName: "Ad", + icon: "fab/buysellads", + description: "Ad", + id: "0emukbsvmzpozx2lzk883", + fields: [ + { + type: "ref", + settings: { + models: [ + { + modelId: "author" + } + ] + }, + multipleValues: true, + label: "Authors", + fieldId: "authors", + id: "tuuehcqp" + } + ] + } + ] + }, + label: "DynamicZone", + fieldId: "dynamicZone", + id: "nli9u1rm" + }, + /** + * Add a dynamic zone field without any templates, to test for correct schema generation. + */ + { + type: "dynamicZone", + settings: { + templates: [] + }, + label: "DynamicZone", + fieldId: "emptyDynamicZone", + id: "lsd78slxc8" + } + ] + } + ] + } + } + ] +}); diff --git a/packages/api-headless-cms/__tests__/contentTraverser/mocks/richTextValue.ts b/packages/api-headless-cms/__tests__/contentTraverser/mocks/richTextValue.ts new file mode 100644 index 00000000000..b43d6f0e278 --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentTraverser/mocks/richTextValue.ts @@ -0,0 +1,275 @@ +export const richTextValue = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Test CMS Title", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "heading-element", + version: 1, + tag: "h1", + styles: [ + { + styleId: "heading1", + type: "typography" + } + ] + }, + { + children: [], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Testing a ", + type: "text", + version: 1 + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "link", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "link", + version: 1, + rel: "noreferrer", + target: null, + title: null, + url: "https://space.com" + }, + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: " for parsing", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Test CMS Paragraph", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + }, + { + children: [], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "Test quote from lexical ", + type: "text", + version: 1 + }, + { + detail: 0, + format: 1, + mode: "normal", + style: "", + text: "CMS", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "webiny-quote", + version: 1, + styles: [ + { + styleId: "quote", + type: "typography" + } + ], + styleId: "quote" + }, + { + children: [], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + }, + { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "List item 1", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "webiny-listitem", + version: 1, + value: 1 + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "List item 2", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "webiny-listitem", + version: 1, + value: 2 + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "List item 3", + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "webiny-listitem", + version: 1, + value: 3 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "webiny-list", + version: 1, + themeStyleId: "list", + listType: "bullet", + start: 1, + tag: "ul" + }, + { + children: [], + direction: null, + format: "", + indent: 0, + type: "paragraph-element", + version: 1, + styles: [ + { + styleId: "paragraph1", + type: "typography" + } + ] + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1 + } +}; diff --git a/packages/api-headless-cms/__tests__/contentTraverser/modelAst.test.ts b/packages/api-headless-cms/__tests__/contentTraverser/modelAst.test.ts new file mode 100644 index 00000000000..ddc70fffd4b --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentTraverser/modelAst.test.ts @@ -0,0 +1,247 @@ +import { useHandler } from "~tests/testHelpers/useHandler"; +import { articleModel } from "./mocks/article.model"; +import { CmsModelPlugin } from "~/plugins"; +import { CmsModelAst } from "~/types"; + +describe("Model to AST", () => { + it("should generate content model AST", async () => { + const { handler, tenant } = useHandler({ + plugins: [new CmsModelPlugin(articleModel)] + }); + + const context = await handler({ + path: "/cms/manage/en-US", + headers: { + "x-webiny-cms-endpoint": "manage", + "x-webiny-cms-locale": "en-US", + "x-tenant": tenant.id + } + }); + + const modelAstConverter = context.cms.getModelToAstConverter(); + const model = await context.cms.getModel("article"); + + if (!model) { + throw new Error(`Missing "article" model!`); + } + + const ast = modelAstConverter.toAst(model); + + expect(ast).toEqual({ + type: "root", + children: [ + { + type: "field", + field: { + id: "title", + multipleValues: false, + label: "Title", + type: "text", + storageId: "text@title", + fieldId: "title" + }, + children: [] + }, + { + type: "field", + field: { + id: "body", + multipleValues: false, + label: "Body", + type: "rich-text", + storageId: "rich-text@body", + fieldId: "body" + }, + children: [] + }, + { + type: "field", + field: { + id: "categories", + multipleValues: true, + label: "Categories", + type: "ref", + storageId: "ref@categories", + fieldId: "categories", + settings: { + models: [{ modelId: "category" }] + } + }, + children: [] + }, + { + type: "field", + field: { + id: "content", + fieldId: "content", + storageId: "dynamicZone@content", + type: "dynamicZone", + label: "Content", + multipleValues: true, + settings: {} + }, + children: [ + { + type: "collection", + collection: { + name: "Hero #1", + gqlTypeName: "Hero", + icon: "fas/flag", + description: "The top piece of content on every page.", + id: "cv2zf965v324ivdc7e1vt", + discriminator: "_templateId" + }, + children: [ + { + type: "field", + field: { + id: "title", + fieldId: "title", + label: "Title", + type: "text" + }, + children: [] + } + ] + }, + { + type: "collection", + collection: { + name: "Simple Text #1", + gqlTypeName: "SimpleText", + icon: "fas/file-text", + description: "Simple paragraph of text.", + id: "81qiz2v453wx9uque0gox", + discriminator: "_templateId" + }, + children: [ + { + type: "field", + field: { + id: "text", + fieldId: "text", + label: "Text", + type: "long-text" + }, + children: [] + } + ] + }, + { + type: "collection", + collection: { + description: "Settings", + gqlTypeName: "Settings", + icon: "fas/file-text", + id: "9ht43gurhegkbdfsaafyads", + name: "Settings", + discriminator: "_templateId" + }, + children: [ + { + type: "field", + field: { + id: "settings", + fieldId: "settings", + label: "Settings", + type: "object", + settings: {} + }, + children: [ + { + type: "field", + field: { + id: "title", + fieldId: "title", + type: "text", + label: "Title" + }, + children: [] + }, + { + type: "field", + field: { + id: "seo", + fieldId: "seo", + type: "object", + label: "SEO", + multipleValues: true, + settings: {} + }, + children: [ + { + type: "field", + field: { + id: "title", + fieldId: "title", + type: "text", + label: "Title" + }, + children: [] + } + ] + } + ] + }, + { + type: "field", + field: { + id: "nli9u1rm", + fieldId: "dynamicZone", + label: "DynamicZone", + type: "dynamicZone", + settings: {} + }, + children: [ + { + type: "collection", + collection: { + name: "Ad", + gqlTypeName: "Ad", + icon: "fab/buysellads", + description: "Ad", + id: "0emukbsvmzpozx2lzk883", + discriminator: "_templateId" + }, + children: [ + { + type: "field", + field: { + id: "tuuehcqp", + fieldId: "authors", + label: "Authors", + type: "ref", + multipleValues: true, + settings: { + models: [ + { + modelId: "author" + } + ] + } + }, + children: [] + } + ] + } + ] + }, + { + type: "field", + field: { + id: "lsd78slxc8", + fieldId: "emptyDynamicZone", + label: "DynamicZone", + type: "dynamicZone", + settings: {} + }, + children: [] + } + ] + } + ] + } + ] + } as CmsModelAst); + }); +}); diff --git a/packages/api-headless-cms/__tests__/contentTraverser/traverser.test.ts b/packages/api-headless-cms/__tests__/contentTraverser/traverser.test.ts new file mode 100644 index 00000000000..9e69bc7b91f --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentTraverser/traverser.test.ts @@ -0,0 +1,50 @@ +import { useHandler } from "~tests/testHelpers/useHandler"; +import { CmsModelPlugin } from "~/plugins"; +import { articleModel } from "./mocks/article.model"; +import { articleEntry } from "./mocks/article.entry"; +import { richTextValue } from "~tests/contentTraverser/mocks/richTextValue"; + +describe("Content Traverser", () => { + it("should traverse model AST and build flat object with entry values", async () => { + const { handler, tenant } = useHandler({ + plugins: [new CmsModelPlugin(articleModel)] + }); + + const context = await handler({ + path: "/cms/manage/en-US", + headers: { + "x-webiny-cms-endpoint": "manage", + "x-webiny-cms-locale": "en-US", + "x-tenant": tenant.id + } + }); + + const traverser = await context.cms.getEntryTraverser("article"); + + const output: Record = {}; + + const skipFieldTypes = ["object", "dynamicZone"]; + + traverser.traverse(articleEntry, ({ field, value, path }) => { + /** + * Most of the time you won't care about complex fields like "object" and "dynamicZone", but only their child fields. + * The traverser will still go into the child fields, but this way you can + */ + if (skipFieldTypes.includes(field.type)) { + return; + } + + output[path] = value; + }); + + expect(output).toEqual({ + title: "Article #1", + body: richTextValue, + categories: [{ modelId: "category", entryId: "12345678" }], + "content.0.title": "Hero #1", + "content.1.settings.title": "Title", + "content.1.settings.seo.0.title": "title-0", + "content.1.settings.seo.1.title": "title-1" + }); + }); +}); diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index e62a9c857d4..72ca1147348 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -80,6 +80,7 @@ import { getPublishedRevisionByEntryIdUseCases, deleteEntryUseCases } from "~/crud/contentEntry/useCases"; +import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser"; interface CreateContentEntryCrudParams { storageOperations: HeadlessCmsStorageOperations; @@ -1161,7 +1162,19 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm } }; + const getEntryTraverser = async (modelId: string) => { + const modelAstConverter = context.cms.getModelToAstConverter(); + const model = await context.cms.getModel(modelId); + if (!model) { + throw new Error(`Missing "${modelId}" model!`); + } + + const modelAst = modelAstConverter.toAst(model); + return new ContentEntryTraverser(modelAst); + }; + return { + getEntryTraverser, onEntryBeforeCreate, onEntryAfterCreate, onEntryCreateError, diff --git a/packages/api-headless-cms/src/crud/contentEntry/referenceFieldsMapping.ts b/packages/api-headless-cms/src/crud/contentEntry/referenceFieldsMapping.ts index 0420170c6ba..2a1f91d2963 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/referenceFieldsMapping.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/referenceFieldsMapping.ts @@ -1,8 +1,8 @@ -import { CmsContext, CmsDynamicZoneTemplate, CmsModel, CmsModelField } from "~/types"; -import WebinyError from "@webiny/error"; import dotProp from "dot-prop"; +import WebinyError from "@webiny/error"; import { parseIdentifier } from "@webiny/utils"; import { getBaseFieldType } from "~/utils/getBaseFieldType"; +import { CmsContext, CmsDynamicZoneTemplate, CmsModel, CmsModelField } from "~/types"; interface CmsRefEntry { id: string; diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 86b458131f9..f01e3397972 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -3,6 +3,7 @@ import { CmsContext, CmsModel, CmsModelContext, + CmsModelFieldToGraphQLPlugin, CmsModelGroup, CmsModelManager, CmsModelUpdateInput, @@ -43,6 +44,10 @@ import { ensureTypeTag } from "./contentModel/ensureTypeTag"; import { listModelsFromDatabase } from "~/crud/contentModel/listModelsFromDatabase"; import { filterAsync } from "~/utils/filterAsync"; import { AccessControl } from "./AccessControl/AccessControl"; +import { + CmsModelToAstConverter, + CmsModelFieldToAstConverterFromPlugins +} from "~/utils/contentModelAst"; export interface CreateModelsCrudParams { getTenant: () => Tenant; @@ -74,6 +79,16 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex return manager; }; + const fieldTypePlugins = context.plugins.byType( + "cms-model-field-to-graphql" + ); + + const getModelToAstConverter = () => { + return new CmsModelToAstConverter( + new CmsModelFieldToAstConverterFromPlugins(fieldTypePlugins) + ); + }; + const listPluginModels = async (tenant: string, locale: string): Promise => { const modelPlugins = context.plugins.byType(CmsModelPlugin.type); const cacheKey = createCacheKey({ @@ -631,6 +646,7 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex onModelInitialize, clearModelsCache, getModel, + getModelToAstConverter, listModels, async createModel(input) { return context.benchmark.measure("headlessCms.crud.models.createModel", async () => { diff --git a/packages/api-headless-cms/src/graphqlFields/dynamicZone/dynamicZoneField.ts b/packages/api-headless-cms/src/graphqlFields/dynamicZone/dynamicZoneField.ts index ac56eefa44b..640cd34c26e 100644 --- a/packages/api-headless-cms/src/graphqlFields/dynamicZone/dynamicZoneField.ts +++ b/packages/api-headless-cms/src/graphqlFields/dynamicZone/dynamicZoneField.ts @@ -174,6 +174,27 @@ export const createDynamicZoneField = }); } }, + getFieldAst: (field, converter) => { + const { templates = [], ...settings } = field.settings; + + return { + type: "field", + field: { + ...field, + settings + }, + children: templates.map(({ fields, ...template }) => { + return { + type: "collection", + collection: { + ...template, + discriminator: "_templateId" + }, + children: fields.map(field => converter.toAst(field)) + }; + }) + }; + }, read: { createTypeField({ models, model, field, fieldTypePlugins }) { const templates = getFieldTemplates(field); diff --git a/packages/api-headless-cms/src/graphqlFields/object.ts b/packages/api-headless-cms/src/graphqlFields/object.ts index ae56b98b94c..595e25b8483 100644 --- a/packages/api-headless-cms/src/graphqlFields/object.ts +++ b/packages/api-headless-cms/src/graphqlFields/object.ts @@ -4,7 +4,8 @@ import { CmsFieldTypePlugins, CmsModel, CmsModelField, - CmsModelFieldToGraphQLPlugin + CmsModelFieldToGraphQLPlugin, + CmsModelObjectField } from "~/types"; import { createTypeFromFields } from "~/utils/createTypeFromFields"; @@ -85,7 +86,7 @@ const createListFilters = ({ field, model }: CreateListFiltersParams) => { return `${field.fieldId}: ${typeName}WhereInput`; }; -export const createObjectField = (): CmsModelFieldToGraphQLPlugin => { +export const createObjectField = (): CmsModelFieldToGraphQLPlugin => { return { name: "cms-model-field-to-graphql-object", type: "cms-model-field-to-graphql", @@ -100,6 +101,18 @@ export const createObjectField = (): CmsModelFieldToGraphQLPlugin => { originalFields: originalField?.settings?.fields || [] }); }, + getFieldAst: (field, converter) => { + const { fields = [], ...settings } = field.settings; + + return { + type: "field", + field: { + ...field, + settings + }, + children: fields.map(field => converter.toAst(field)) + }; + }, read: { createTypeField({ field, models, model, fieldTypePlugins }) { const result = createTypeFromFields({ diff --git a/packages/api-headless-cms/src/types/context.ts b/packages/api-headless-cms/src/types/context.ts new file mode 100644 index 00000000000..266a62ed1d2 --- /dev/null +++ b/packages/api-headless-cms/src/types/context.ts @@ -0,0 +1,233 @@ +import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser"; +import { Topic } from "@webiny/pubsub/types"; +import { + CmsDeleteEntryOptions, + CmsEntry, + CmsEntryGetParams, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryUniqueValue, + CmsEntryValidateResponse, + CmsEntryValues, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput, + CreateFromCmsEntryInput, + CreateRevisionCmsEntryOptionsInput, + DeleteMultipleEntriesParams, + DeleteMultipleEntriesResponse, + EntryBeforeListTopicParams, + GetUniqueFieldValuesParams, + OnEntryAfterCreateTopicParams, + OnEntryAfterDeleteTopicParams, + OnEntryAfterMoveTopicParams, + OnEntryAfterPublishTopicParams, + OnEntryAfterRepublishTopicParams, + OnEntryAfterUnpublishTopicParams, + OnEntryAfterUpdateTopicParams, + OnEntryBeforeCreateTopicParams, + OnEntryBeforeDeleteTopicParams, + OnEntryBeforeGetTopicParams, + OnEntryBeforeMoveTopicParams, + OnEntryBeforePublishTopicParams, + OnEntryBeforeRepublishTopicParams, + OnEntryBeforeUnpublishTopicParams, + OnEntryBeforeUpdateTopicParams, + OnEntryCreateErrorTopicParams, + OnEntryCreateRevisionErrorTopicParams, + OnEntryDeleteErrorTopicParams, + OnEntryMoveErrorTopicParams, + OnEntryPublishErrorTopicParams, + OnEntryRepublishErrorTopicParams, + OnEntryRevisionAfterCreateTopicParams, + OnEntryRevisionAfterDeleteTopicParams, + OnEntryRevisionBeforeCreateTopicParams, + OnEntryRevisionBeforeDeleteTopicParams, + OnEntryRevisionDeleteErrorTopicParams, + OnEntryUnpublishErrorTopicParams, + OnEntryUpdateErrorTopicParams, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "./types"; +import { CmsModel } from "./model"; + +/** + * Cms Entry CRUD methods in the context. + * + * @category Context + * @category CmsEntry + */ +export interface CmsEntryContext { + /** + * Get content entry traverser. + */ + getEntryTraverser: (modelId: string) => Promise; + /** + * Get a single content entry for a model. + */ + getEntry: (model: CmsModel, params: CmsEntryGetParams) => Promise; + /** + * Get a list of entries for a model by a given ID (revision). + */ + getEntriesByIds: (model: CmsModel, revisions: string[]) => Promise; + /** + * Get the entry for a model by a given ID. + */ + getEntryById: (model: CmsModel, revision: string) => Promise; + /** + * List entries for a model. Internal method used by get, listLatest and listPublished. + */ + listEntries: ( + model: CmsModel, + params: CmsEntryListParams + ) => Promise<[CmsEntry[], CmsEntryMeta]>; + /** + * Lists the latest entries. Used for manage API. + */ + listLatestEntries: ( + model: CmsModel, + params?: CmsEntryListParams + ) => Promise<[CmsEntry[], CmsEntryMeta]>; + /** + * List published entries. Used for read API. + */ + listPublishedEntries: ( + model: CmsModel, + params?: CmsEntryListParams + ) => Promise<[CmsEntry[], CmsEntryMeta]>; + /** + * Lists the deleted entries. Used for manage API. + */ + listDeletedEntries: ( + model: CmsModel, + params?: CmsEntryListParams + ) => Promise<[CmsEntry[], CmsEntryMeta]>; + /** + * List published entries by IDs. + */ + getPublishedEntriesByIds: (model: CmsModel, ids: string[]) => Promise; + /** + * List latest entries by IDs. + */ + getLatestEntriesByIds: (model: CmsModel, ids: string[]) => Promise; + /** + * Create a new content entry. + */ + createEntry: ( + model: CmsModel, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ) => Promise; + /** + * Create a new entry from already existing entry. + */ + createEntryRevisionFrom: ( + model: CmsModel, + id: string, + input: CreateFromCmsEntryInput, + options?: CreateRevisionCmsEntryOptionsInput + ) => Promise; + /** + * Update existing entry. + */ + updateEntry: ( + model: CmsModel, + id: string, + input: UpdateCmsEntryInput, + meta?: Record, + options?: UpdateCmsEntryOptionsInput + ) => Promise; + /** + * Validate the entry - either new one or existing one. + */ + validateEntry: ( + model: CmsModel, + id?: string, + input?: UpdateCmsEntryInput + ) => Promise; + /** + * Move entry, and all its revisions, to a new folder. + */ + moveEntry: (model: CmsModel, id: string, folderId: string) => Promise; + /** + * Method that republishes entry with given identifier. + * @internal + */ + republishEntry: (model: CmsModel, id: string) => Promise; + /** + * Delete only a certain revision of the entry. + */ + deleteEntryRevision: (model: CmsModel, id: string) => Promise; + /** + * Delete entry with all its revisions. + */ + deleteEntry: (model: CmsModel, id: string, options?: CmsDeleteEntryOptions) => Promise; + /** + * Delete multiple entries + */ + deleteMultipleEntries: ( + model: CmsModel, + params: DeleteMultipleEntriesParams + ) => Promise; + /** + * Publish entry. + */ + publishEntry: (model: CmsModel, id: string) => Promise; + /** + * Unpublish entry. + */ + unpublishEntry: (model: CmsModel, id: string) => Promise; + /** + * Get all entry revisions. + */ + getEntryRevisions: (model: CmsModel, id: string) => Promise; + /** + * List all unique values for a given field. + * + * @internal + */ + getUniqueFieldValues: ( + model: CmsModel, + params: GetUniqueFieldValuesParams + ) => Promise; + /** + * Lifecycle Events + */ + onEntryBeforeCreate: Topic; + onEntryAfterCreate: Topic; + onEntryCreateError: Topic; + + onEntryRevisionBeforeCreate: Topic; + onEntryRevisionAfterCreate: Topic; + onEntryRevisionCreateError: Topic; + + onEntryBeforeUpdate: Topic; + onEntryAfterUpdate: Topic; + onEntryUpdateError: Topic; + + onEntryBeforeMove: Topic; + onEntryAfterMove: Topic; + onEntryMoveError: Topic; + + onEntryBeforeDelete: Topic; + onEntryAfterDelete: Topic; + onEntryDeleteError: Topic; + + onEntryRevisionBeforeDelete: Topic; + onEntryRevisionAfterDelete: Topic; + onEntryRevisionDeleteError: Topic; + + onEntryBeforePublish: Topic; + onEntryAfterPublish: Topic; + onEntryPublishError: Topic; + + onEntryBeforeRepublish: Topic; + onEntryAfterRepublish: Topic; + onEntryRepublishError: Topic; + + onEntryBeforeUnpublish: Topic; + onEntryAfterUnpublish: Topic; + onEntryUnpublishError: Topic; + + onEntryBeforeGet: Topic; + onEntryBeforeList: Topic; +} diff --git a/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts b/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts new file mode 100644 index 00000000000..b4db516932d --- /dev/null +++ b/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts @@ -0,0 +1,25 @@ +import { CmsModelField, CmsModelFieldValidation } from "../modelField"; + +export interface CmsDynamicZoneTemplate { + id: string; + name: string; + gqlTypeName: string; + description: string; + icon: string; + fields: CmsModelField[]; + layout: string[][]; + validation: CmsModelFieldValidation[]; + tags?: string[]; +} + +/** + * A definition for dynamic-zone field to show possible type of the field in settings. + */ +export interface CmsModelDynamicZoneField extends CmsModelField { + /** + * Settings object for the field. Contains `templates` property. + */ + settings: { + templates: CmsDynamicZoneTemplate[]; + }; +} diff --git a/packages/api-headless-cms/src/types/fields/objectField.ts b/packages/api-headless-cms/src/types/fields/objectField.ts new file mode 100644 index 00000000000..55323881ee1 --- /dev/null +++ b/packages/api-headless-cms/src/types/fields/objectField.ts @@ -0,0 +1,14 @@ +import { CmsModelField } from "../modelField"; + +/** + * A definition for object field to show possible type of the field in settings. + */ +export interface CmsModelObjectField extends CmsModelField { + /** + * Settings object for the field. Contains `templates` property. + */ + settings: { + fields: CmsModelField[]; + parents?: string[]; + }; +} diff --git a/packages/api-headless-cms/src/types/identity.ts b/packages/api-headless-cms/src/types/identity.ts new file mode 100644 index 00000000000..e38598e99c5 --- /dev/null +++ b/packages/api-headless-cms/src/types/identity.ts @@ -0,0 +1,19 @@ +/** + * An interface describing the reference to a user that created some data in the database. + * + * @category General + */ +export interface CmsIdentity { + /** + * ID if the user. + */ + id: string; + /** + * Full name of the user. + */ + displayName: string | null; + /** + * Type of the user (admin, user) + */ + type: string; +} diff --git a/packages/api-headless-cms/src/types/index.ts b/packages/api-headless-cms/src/types/index.ts new file mode 100644 index 00000000000..e7e926f2bfc --- /dev/null +++ b/packages/api-headless-cms/src/types/index.ts @@ -0,0 +1,10 @@ +export * from "./types"; +export * from "./modelAst"; +export * from "./context"; +export * from "./identity"; +export * from "./model"; +export * from "./modelField"; +export * from "./modelGroup"; +export * from "./plugins"; +export * from "./fields/dynamicZoneField"; +export * from "./fields/objectField"; diff --git a/packages/api-headless-cms/src/types/model.ts b/packages/api-headless-cms/src/types/model.ts new file mode 100644 index 00000000000..7ba3b0d6349 --- /dev/null +++ b/packages/api-headless-cms/src/types/model.ts @@ -0,0 +1,207 @@ +import { CmsIdentity } from "./identity"; +import { CmsModelField, CmsModelFieldInput, LockedField } from "./modelField"; +import { CmsModelGroup } from "./modelGroup"; + +/** + * Base CMS Model. Should not be exported and used outside of this package. + * + * @category Database model + * @category CmsModel + */ +export interface CmsModel { + /** + * Name of the content model. + */ + name: string; + /** + * Unique ID for the content model. Created from name if not defined by user. + */ + modelId: string; + /** + * Name of the content model in singular form to be used in the API. + * example: + * - Article + * - Fruit + * - Vegetable + * - Car + */ + singularApiName: string; + /** + * Name of the content model in plural form to be used in the API. + * example: + * - Articles + * - Fruits + * - Vegetables + * - Cars + */ + pluralApiName: string; + /** + * Model tenant. + */ + tenant: string; + /** + * Locale this model belongs to. + */ + locale: string; + /** + * Cms Group reference object. + */ + group: CmsModelGroup; + /** + * Icon for the content model. + */ + icon?: string | null; + /** + * Description for the content model. + */ + description: string | null; + /** + * Date created + */ + createdOn?: string; + /** + * Date saved. Changes on both save and create. + */ + savedOn?: string; + /** + * CreatedBy object wrapper. Contains id, name and type of the user. + */ + createdBy?: CmsIdentity; + /** + * List of fields defining entry values. + */ + fields: CmsModelField[]; + /** + * Admin UI field layout + * + * ```ts + * layout: [ + * [field1id, field2id], + * [field3id] + * ] + * ``` + */ + layout: string[][]; + /** + * Models can be tagged to give them contextual meaning. + */ + tags?: string[]; + /** + * List of locked fields. Updated when entry is saved and a field has been used. + */ + lockedFields?: LockedField[]; + /** + * The field that is being displayed as entry title. + * It is picked as first available text field. Or user can select own field. + */ + titleFieldId: string; + /** + * The field which is displayed as the description one. + * Only way this is null or undefined is that there are no long-text fields to be set as description. + */ + descriptionFieldId?: string | null; + /** + * The field which is displayed as the image. + * Only way this is null or undefined is that there are no file fields, with images only set, to be set as image. + */ + imageFieldId?: string | null; + /** + * The version of Webiny which this record was stored with. + */ + webinyVersion: string; + + /** + * Is model private? + * This is meant to be used for some internal models - will not be visible in the schema. + * Only available for the plugin constructed models. + */ + isPrivate?: boolean; + + /** + * Does this model require authorization to be performed? + * Only available for models created via plugins. + */ + authorization?: boolean | CmsModelAuthorization; + + /** + * Is this model created via plugin? + */ + isPlugin?: boolean; +} + +export interface CmsModelAuthorization { + permissions: boolean; + + [key: string]: any; +} + +/** + * A GraphQL `params.data` parameter received when creating content model. + * + * @category GraphQL params + * @category CmsModel + */ +export interface CmsModelCreateInput { + /** + * Name of the content model. + */ + name: string; + /** + * Singular name of the content model to be used in the API. + */ + singularApiName: string; + /** + * Plural name of the content model to be used in the API. + */ + pluralApiName: string; + /** + * Unique ID of the content model. Created from name if not sent by the user. Cannot be changed. + */ + modelId?: string; + /** + * Description of the content model. + */ + description?: string | null; + /** + * Group where to put the content model in. + */ + group: string; + /** + * A list of content model fields to define the entry values. + */ + fields?: CmsModelFieldInput[]; + /** + * Admin UI field layout + * + * ```ts + * layout: [ + * [field1id, field2id], + * [field3id] + * ] + * ``` + */ + layout?: string[][]; + /** + * Models can be tagged to give them contextual meaning. + */ + tags?: string[]; + /** + * Fields fieldId which are picked to represent the CMS entry. + */ + titleFieldId?: string | null; + descriptionFieldId?: string | null; + imageFieldId?: string | null; +} + +/** + * A GraphQL `params.data` parameter received when creating content model from existing model. + * + * @category GraphQL params + * @category CmsModel + */ +export interface CmsModelCreateFromInput extends CmsModelCreateInput { + /** + * Locale into which we want to clone the model into. + */ + locale?: string; +} diff --git a/packages/api-headless-cms/src/types/modelAst.ts b/packages/api-headless-cms/src/types/modelAst.ts new file mode 100644 index 00000000000..b546782630d --- /dev/null +++ b/packages/api-headless-cms/src/types/modelAst.ts @@ -0,0 +1,48 @@ +/** + * CMS Model AST + */ +import { CmsModelField } from "./modelField"; + +export interface ICmsModelFieldToAst { + toAst(field: CmsModelField): CmsModelFieldAstNode; +} + +export type CmsModelAst = { + type: "root"; + children: CmsModelFieldAstNode[]; +}; + +export type CmsModelFieldAstNodeCollection = { + type: "collection"; + collection: { discriminator: string } & Record; + children: CmsModelFieldAstNode[]; +}; + +export type CmsModelFieldAstNodeField = { + type: "field"; + field: CmsModelField; + children: CmsModelFieldAstNode[]; +}; + +export type CmsModelFieldAstNode = CmsModelFieldAstNodeCollection | CmsModelFieldAstNodeField; + +export interface GetCmsModelFieldAst { + (field: TField, converter: ICmsModelFieldToAst): CmsModelFieldAstNode; +} + +export interface ContentEntryNode { + field: CmsModelField; + value: any; + path: string; + // parentField: CmsModelField | undefined; + // parent => parent object (e.g., entry) +} + +export interface ContentEntryNodeContext { + node: CmsModelAst | CmsModelFieldAstNode; + parent: CmsModelAst | CmsModelFieldAstNode | null; +} + +export interface ContentEntryValueVisitor { + (params: ContentEntryNode, context: ContentEntryNodeContext): Promise | void; +} diff --git a/packages/api-headless-cms/src/types/modelField.ts b/packages/api-headless-cms/src/types/modelField.ts new file mode 100644 index 00000000000..fb85544c36e --- /dev/null +++ b/packages/api-headless-cms/src/types/modelField.ts @@ -0,0 +1,343 @@ +import { CmsModel } from "./model"; + +export type CmsModelFieldType = + | "boolean" + | "datetime" + | "file" + | "long-text" + | "number" + | "json" + | "object" + | "ref" + | "rich-text" + | "text" + | "dynamicZone" + | string; + +/** + * A definition for content model field. This type exists on the app side as well. + * + * @category ModelField + * @category Database model + */ +export interface CmsModelField { + /** + * A generated unique ID for the model field. + * MUST be absolute unique throughout the models. + * Must be in form of a-zA-Z0-9. + * + * We generate a unique id value when you're building a model via UI, + * but when user is creating a model via a plugin it is up to them to be careful about this. + */ + id: string; + /** + * A type of the field. + * We are defining our built-in fields, so people know which are available by the default. + */ + type: CmsModelFieldType; + /** + * A unique storage ID for storing actual values. + * Must in form of a-zA-Z0-9@a-zA-Z0-9 + * + * This is an auto-generated value: uses `id` and `type` + * + * This is used as path for the entry value. + */ + storageId: `${string}@${string}` | string; + /** + * Field identifier for the model field that will be available to the outside world. + * `storageId` is used as path (or column) to store the data. + * + * Must in form of a-zA-Z0-9. + * + * This value MUST be unique in the CmsModel. + */ + fieldId: string; + /** + * A label for the field + */ + label: string; + /** + * Text below the field to clarify what is it meant to be in the field value + */ + helpText?: string | null; + /** + * Text to be displayed in the field + */ + placeholderText?: string | null; + /** + * Are predefined values enabled? And list of them + */ + predefinedValues?: CmsModelFieldPredefinedValues; + /** + * Field renderer. Blank if determined automatically. + */ + renderer?: CmsModelFieldRenderer; + /** + * List of validations for the field + * + * @default [] + */ + validation?: CmsModelFieldValidation[]; + /** + * List of validations for the list of values, when a field is set to accept a list of values. + * These validations will be applied to the entire list, and `validation` (see above) will be applied + * to each individual value in the list. + * + * @default [] + */ + listValidation?: CmsModelFieldValidation[]; + /** + * Is this a multiple values field? + * + */ + multipleValues?: boolean; + /** + * Fields can be tagged to give them contextual meaning. + */ + tags?: string[]; + /** + * Any user defined settings. + * + * @default {} + */ + settings?: CmsModelFieldSettings; +} + +/** + * A definition for content model field received from the user. + * + * Input type for `CmsModelField`. + * @see CmsModelField + * + * @category GraphQL params + * @category ModelField + */ +export interface CmsModelFieldInput { + /** + * Generated ID. + */ + id: string; + /** + * Type of the field. A plugin for the field must be defined. + * @see CmsModelFieldToGraphQLPlugin + */ + type: string; + /** + * Field outside world identifier for the field. Must be unique in the model. + */ + fieldId: string; + /** + * Label for the field. + */ + label: string; + /** + * Text to display below the field to help user what to write in the field. + */ + helpText?: string | null; + /** + * Text to display in the field. + */ + placeholderText?: string | null; + /** + * Fields can be tagged to give them contextual meaning. + */ + tags?: string[]; + /** + * Are multiple values allowed? + */ + multipleValues?: boolean; + /** + * Predefined values options for the field. Check the reference for more information. + */ + predefinedValues?: CmsModelFieldPredefinedValues; + /** + * Renderer options for the field. Check the reference for more information. + */ + renderer?: CmsModelFieldRenderer; + /** + * List of validations for the field. + */ + validation?: CmsModelFieldValidation[]; + /** + * @see CmsModelField.listValidation + */ + listValidation?: CmsModelFieldValidation[]; + /** + * User defined settings. + */ + settings?: Record; +} + +/** + * A GraphQL `params.data` parameter received when updating content model. + * + * @category GraphQL params + * @category CmsModel + */ +export interface CmsModelUpdateInput { + /** + * A new content model name. + */ + name?: string; + /** + * A new singular name of the content model to be used in the API. + */ + singularApiName?: string; + /** + * A new plural name of the content model to be used in the API. + */ + pluralApiName?: string; + /** + * A group we want to move the model to. + */ + group?: string; + /** + * A new description of the content model. + */ + description?: string | null; + /** + * A list of content model fields to define the entry values. + */ + fields: CmsModelFieldInput[]; + /** + * Admin UI field layout + * + * ```ts + * layout: [ + * [field1id, field2id], + * [field3id] + * ] + * ``` + */ + layout: string[][]; + /** + * Fields fieldId which are picked to represent the CMS entry. + */ + titleFieldId?: string | null; + descriptionFieldId?: string | null; + imageFieldId?: string | null; +} + +/** + * Locked field in the content model + * + * @see CmsModel.lockedFields + * + * @category ModelField + */ +export interface LockedField { + /** + * Locked field storage ID - one used to store values. + * We cannot change this due to old systems. + */ + fieldId: string; + /** + * Is the field multiple values field? + */ + multipleValues: boolean; + /** + * Field type. + */ + type: string; + + [key: string]: any; +} + +/** + * Object containing content model field renderer options. + * + * @category CmsModelField + */ +interface CmsModelFieldRenderer { + /** + * Name of the field renderer. Must have one in field renderer plugins. + * Can be blank to let automatically determine the renderer. + */ + name: string; +} + +/** + * A definition for content model field settings. + * + * @category ModelField + * @category Database model + */ +export interface CmsModelFieldSettings { + /** + * Predefined values (text, number) + * The default value for the field in case it is not predefined values field. + */ + defaultValue?: string | number | null | undefined; + /** + * Object field has child fields. + */ + fields?: CmsModelField[]; + /** + * Is the file field images only one? + */ + imagesOnly?: boolean; + /** + * Object field has child fields - so it needs to have a layout. + */ + layout?: string[][]; + /** + * Ref field. + */ + models?: Pick[]; + /** + * Date field. + */ + type?: string; + /** + * Disable full text search explicitly on this field. + */ + disableFullTextSearch?: boolean; + + /** + * There are a lot of other settings that are possible to add, so we keep the type opened. + */ + [key: string]: any; +} + +/** + * Definition for content model field validator. + * + * @category ModelField + * @category FieldValidation + */ +export interface CmsModelFieldValidation { + name: string; + message: string; + settings?: { + value?: string | number; + values?: string[]; + preset?: string; + [key: string]: any; + }; +} + +/** + * Object containing content model field predefined options and values. + * + * @category CmsModelField + */ +export interface CmsModelFieldPredefinedValues { + /** + * Are predefined field values enabled? + */ + enabled: boolean; + /** + * Predefined values array. + */ + values: CmsModelFieldPredefinedValuesValue[]; +} + +interface CmsModelFieldPredefinedValuesValue { + value: string; + label: string; + /** + * Default selected predefined value. + */ + selected?: boolean; +} diff --git a/packages/api-headless-cms/src/types/modelGroup.ts b/packages/api-headless-cms/src/types/modelGroup.ts new file mode 100644 index 00000000000..136aec5c345 --- /dev/null +++ b/packages/api-headless-cms/src/types/modelGroup.ts @@ -0,0 +1,79 @@ +import { CmsIdentity } from "./identity"; + +/** + * @category Database model + * @category CmsModel + */ +export interface CmsModelGroup { + /** + * Generated ID of the group + */ + id: string; + /** + * Name of the group + */ + name: string; +} + +/** + * A representation of content model group in the database. + * + * @category CmsGroup + * @category Database model + */ +export interface CmsGroup { + /** + * Generated ID. + */ + id: string; + /** + * Name of the group. + */ + name: string; + /** + * Slug for the group. Must be unique. + */ + slug: string; + /** + * Group tenant. + */ + tenant: string; + /** + * Locale this group belongs to. + */ + locale: string; + /** + * Description for the group. + */ + description: string | null; + /** + * Icon for the group. In a form of "ico/ico". + */ + icon: string; + /** + * CreatedBy reference object. + */ + createdBy?: CmsIdentity; + /** + * Date group was created on. + */ + createdOn?: string; + /** + * Date group was created or changed on. + */ + savedOn?: string; + /** + * Which Webiny version was this record stored with. + */ + webinyVersion: string; + /** + * Is group private? + * This is meant to be used for some internal groups - will not be visible in the schema. + * Only available for the plugin constructed groups. + */ + isPrivate?: boolean; + /** + * Is this group created via plugin? + */ + isPlugin?: boolean; +} diff --git a/packages/api-headless-cms/src/types/plugins.ts b/packages/api-headless-cms/src/types/plugins.ts new file mode 100644 index 00000000000..f0fdba6899f --- /dev/null +++ b/packages/api-headless-cms/src/types/plugins.ts @@ -0,0 +1,366 @@ +import { Plugin } from "@webiny/plugins/types"; +import { GraphQLSchemaDefinition } from "@webiny/handler-graphql/types"; +import { + CmsContext, + CmsFieldTypePlugins, + CmsModelFieldDefinition, + CmsModelFieldToGraphQLCreateResolver, + CmsModelFieldToGraphQLPluginValidateChildFields, + CmsModelFieldValidatorValidateParams +} from "./types"; +import { GetCmsModelFieldAst } from "./modelAst"; +import { CmsModelField, CmsModelFieldType, LockedField } from "./modelField"; +import { CmsModel } from "./model"; + +/** + * @category Plugin + * @category ModelField + * @category GraphQL + */ +export interface CmsModelFieldToGraphQLPlugin + extends Plugin { + /** + * A plugin type + */ + type: "cms-model-field-to-graphql"; + /** + * Field type name which must be exact as the one in `CmsEditorFieldTypePlugin` plugin. + * + * ```ts + * fieldType: "myField" + * ``` + */ + fieldType: CmsModelFieldType; + /** + * Is the field searchable via the GraphQL? + * + * ```ts + * isSearchable: false + * ``` + */ + isSearchable: boolean; + /** + * Is the field searchable via full text search? + * + * Field is not full text searchable by default. + * ```ts + * fullTextSearch: false + * ``` + */ + fullTextSearch?: boolean; + /** + * Is the field sortable via the GraphQL? + * + * ```ts + * isSortable: true + * ``` + */ + isSortable: boolean; + /** + * Optional method which creates the storageId. + * Primary use is for the datetime field, but if users has some specific fields, they can customize the storageId to their needs. + * + * ```ts + * createStorageId: ({field}) => { + * if (field.settings.type === "time) { + * return `${field.type}_time@${field.id}` + * } + * // use default method + * return undefined; + * } + * ``` + */ + createStorageId?: (params: { + model: CmsModel; + field: Omit & Partial>; + }) => string | null | undefined; + /** + * Read API methods. + */ + read: { + /** + * Definition for get filtering for GraphQL. + * + * ```ts + * read: { + * createGetFilters({ field }) { + * return `${field.fieldId}: MyField`; + * } + * } + * ``` + */ + createGetFilters?(params: { field: TField }): string; + /** + * Definition for list filtering for GraphQL. + * + * ```ts + * read: { + * createListFilters({ field }) { + * return ` + * ${field.fieldId}: MyType + * ${field.fieldId}_not: MyType + * ${field.fieldId}_in: [MyType] + * ${field.fieldId}_not_in: [MyType] + * `; + * } + * } + * ``` + */ + createListFilters?(params: { + model: Pick; + field: TField; + plugins: CmsFieldTypePlugins; + }): string; + /** + * Definition of the field type for GraphQL - be aware if multiple values is selected. + * + * ```ts + * read: { + * createTypeField({ field }) { + * if (field.multipleValues) { + * return `${field.fieldId}: [MyFieldType]`; + * } + * + * return `${field.fieldId}: MyField`; + * } + * } + * ``` + */ + createTypeField(params: { + models: CmsModel[]; + model: CmsModel; + field: TField; + fieldTypePlugins: CmsFieldTypePlugins; + }): CmsModelFieldDefinition | string | null; + /** + * Definition for field resolver. + * By default, it is simple return of the `instance.values[storageId]` but if required, users can define their own. + * + * ```ts + * read: { + * createResolver({ field }) { + * return instance => { + * return instance.values[field.storageId]; + * }; + * } + * } + * ``` + */ + createResolver?: CmsModelFieldToGraphQLCreateResolver; + /** + * Read API schema definitions for the field and resolvers for them. + * + * ```ts + * read: { + * createSchema() { + * return { + * typeDefs: ` + * myField { + * id + * date + * } + * `, + * resolvers: {} + * } + * } + * } + * ``` + */ + createSchema?: (params: { models: CmsModel[] }) => GraphQLSchemaDefinition; + }; + manage: { + /** + * Definition for list filtering for GraphQL. + * + * ```ts + * manage: { + * createListFilters({ field }) { + * return ` + * ${field.fieldId}: MyType + * ${field.fieldId}_not: MyType + * ${field.fieldId}_in: [MyType] + * ${field.fieldId}_not_in: [MyType] + * `; + * } + * } + * ``` + */ + createListFilters?: (params: { + model: Pick; + field: TField; + plugins: CmsFieldTypePlugins; + }) => string; + /** + * Manage API schema definitions for the field and resolvers for them. Probably similar to `read.createSchema`. + * + * ```ts + * createSchema() { + * return { + * typeDefs: ` + * myField { + * id + * date + * } + * `, + * resolvers: {} + * } + * } + * ``` + */ + createSchema?: (params: { models: CmsModel[] }) => GraphQLSchemaDefinition; + /** + * Definition of the field type for GraphQL - be aware if multiple values is selected. Probably same as `read.createTypeField`. + * + * ```ts + * manage: { + * createTypeField({ field }) { + * if (field.multipleValues) { + * return field.fieldId + ": [MyType]"; + * } + * + * return field.fieldId + ": MyType"; + * } + * } + * ``` + */ + createTypeField: (params: { + models: CmsModel[]; + model: CmsModel; + field: TField; + fieldTypePlugins: CmsFieldTypePlugins; + }) => CmsModelFieldDefinition | string | null; + /** + * Definition for input GraphQL field type. + * + * ```ts + * manage: { + * createInputField({ field }) { + * if (field.multipleValues) { + * return field.fieldId + ": [MyField]"; + * } + * + * return field.fieldId + ": MyField"; + * } + * } + * ``` + */ + createInputField: (params: { + models: CmsModel[]; + model: CmsModel; + field: TField; + fieldTypePlugins: CmsFieldTypePlugins; + }) => CmsModelFieldDefinition | string | null; + /** + * Definition for field resolver. + * By default, it is simple return of the `instance.values[storageId]` but if required, users can define their own. + * + * ```ts + * manage: { + * createResolver({ field }) { + * return instance => { + * return instance.values[field.storageId]; + * }; + * } + * } + * ``` + */ + createResolver?: CmsModelFieldToGraphQLCreateResolver; + }; + /** + * + * @param field + */ + validateChildFields?: CmsModelFieldToGraphQLPluginValidateChildFields; + /** + * Get field AST. + */ + getFieldAst?: GetCmsModelFieldAst; +} + +/** + * Check for content model locked field. + * A custom plugin definable by the user. + * + * @category CmsModel + * @category Plugin + */ +export interface CmsModelLockedFieldPlugin extends Plugin { + /** + * A plugin type + */ + type: "cms-model-locked-field"; + /** + * A unique identifier of the field type (text, number, json, myField, ...). + */ + fieldType: string; + /** + * A method to check if field really is locked. + */ + checkLockedField?: (params: { lockedField: LockedField; field: CmsModelField }) => void; + /** + * A method to get the locked field data. + */ + getLockedFieldData?: (params: { field: CmsModelField }) => Record; +} + +/** + * Definition for the field validator. + * + * @category Plugin + * @category ModelField + * @category FieldValidation + */ +export interface CmsModelFieldValidatorPluginValidateCb { + (params: CmsModelFieldValidatorValidateParams): Promise; +} + +export interface CmsModelFieldValidatorPlugin extends Plugin { + /** + * A plugin type. + */ + type: "cms-model-field-validator"; + /** + * Actual validator definition. + */ + validator: { + /** + * Name of the validator. + */ + name: string; + /** + * Validation method. + */ + validate: CmsModelFieldValidatorPluginValidateCb; + }; +} + +/** + * A pattern validator for the content entry field value. + * + * @category Plugin + * @category ModelField + * @category FieldValidation + */ +export interface CmsModelFieldPatternValidatorPlugin extends Plugin { + /** + * A plugin type + */ + type: "cms-model-field-validator-pattern"; + /** + * A pattern object for the validator. + */ + pattern: { + /** + * name of the pattern. + */ + name: string; + /** + * RegExp of the validator. + */ + regex: string; + /** + * RegExp flags + */ + flags: string; + }; +} diff --git a/packages/api-headless-cms/src/types.ts b/packages/api-headless-cms/src/types/types.ts similarity index 61% rename from packages/api-headless-cms/src/types.ts rename to packages/api-headless-cms/src/types/types.ts index a0c86b184db..39c3719643d 100644 --- a/packages/api-headless-cms/src/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1,17 +1,20 @@ import { Plugin } from "@webiny/plugins/types"; import { I18NContext, I18NLocale } from "@webiny/api-i18n/types"; import { Context } from "@webiny/api/types"; -import { - GraphQLFieldResolver, - GraphQLSchemaDefinition, - Resolvers -} from "@webiny/handler-graphql/types"; +import { GraphQLFieldResolver, Resolvers } from "@webiny/handler-graphql/types"; import { SecurityPermission } from "@webiny/api-security/types"; import { DbContext } from "@webiny/handler-db/types"; import { Topic } from "@webiny/pubsub/types"; import { CmsModelConverterCallable } from "~/utils/converters/ConverterCollection"; import { HeadlessCmsExport, HeadlessCmsImport } from "~/export/types"; import { AccessControl } from "~/crud/AccessControl/AccessControl"; +import { CmsModelToAstConverter } from "~/utils/contentModelAst/CmsModelToAstConverter"; +import { CmsModelFieldToGraphQLPlugin } from "./plugins"; +import { CmsEntryContext } from "./context"; +import { CmsModelField, CmsModelFieldValidation, CmsModelUpdateInput } from "./modelField"; +import { CmsModel, CmsModelCreateFromInput, CmsModelCreateInput } from "./model"; +import { CmsGroup } from "./modelGroup"; +import { CmsIdentity } from "./identity"; export type ApiEndpoint = "manage" | "preview" | "read"; @@ -70,215 +73,6 @@ export interface CmsContext extends Context, DbContext, I18NContext { cms: HeadlessCms; } -interface CmsModelFieldPredefinedValuesValue { - value: string; - label: string; - /** - * Default selected predefined value. - */ - selected?: boolean; -} - -/** - * Object containing content model field predefined options and values. - * - * @category CmsModelField - */ -export interface CmsModelFieldPredefinedValues { - /** - * Are predefined field values enabled? - */ - enabled: boolean; - /** - * Predefined values array. - */ - values: CmsModelFieldPredefinedValuesValue[]; -} - -/** - * Object containing content model field renderer options. - * - * @category CmsModelField - */ -interface CmsModelFieldRenderer { - /** - * Name of the field renderer. Must have one in field renderer plugins. - * Can be blank to let automatically determine the renderer. - */ - name: string; -} - -/** - * A definition for content model field settings. - * - * @category ModelField - * @category Database model - */ -export interface CmsModelFieldSettings { - /** - * Predefined values (text, number) - * The default value for the field in case it is not predefined values field. - */ - defaultValue?: string | number | null | undefined; - /** - * Object field has child fields. - */ - fields?: CmsModelField[]; - /** - * Is the file field images only one? - */ - imagesOnly?: boolean; - /** - * Object field has child fields - so it needs to have a layout. - */ - layout?: string[][]; - /** - * Ref field. - */ - models?: Pick[]; - /** - * Date field. - */ - type?: string; - /** - * Disable full text search explicitly on this field. - */ - disableFullTextSearch?: boolean; - - /** - * There are a lot of other settings that are possible to add, so we keep the type opened. - */ - [key: string]: any; -} - -export type CmsModelFieldType = - | "boolean" - | "datetime" - | "file" - | "long-text" - | "number" - | "json" - | "object" - | "ref" - | "rich-text" - | "text" - | "dynamicZone" - | string; - -/** - * A definition for content model field. This type exists on the app side as well. - * - * @category ModelField - * @category Database model - */ -export interface CmsModelField { - /** - * A generated unique ID for the model field. - * MUST be absolute unique throughout the models. - * Must be in form of a-zA-Z0-9. - * - * We generate a unique id value when you're building a model via UI, - * but when user is creating a model via a plugin it is up to them to be careful about this. - */ - id: string; - /** - * A type of the field. - * We are defining our built-in fields, so people know which are available by the default. - */ - type: CmsModelFieldType; - /** - * A unique storage ID for storing actual values. - * Must in form of a-zA-Z0-9@a-zA-Z0-9 - * - * This is an auto-generated value: uses `id` and `type` - * - * This is used as path for the entry value. - */ - storageId: `${string}@${string}` | string; - /** - * Field identifier for the model field that will be available to the outside world. - * `storageId` is used as path (or column) to store the data. - * - * Must in form of a-zA-Z0-9. - * - * This value MUST be unique in the CmsModel. - */ - fieldId: string; - /** - * A label for the field - */ - label: string; - /** - * Text below the field to clarify what is it meant to be in the field value - */ - helpText?: string | null; - /** - * Text to be displayed in the field - */ - placeholderText?: string | null; - /** - * Are predefined values enabled? And list of them - */ - predefinedValues?: CmsModelFieldPredefinedValues; - /** - * Field renderer. Blank if determined automatically. - */ - renderer?: CmsModelFieldRenderer; - /** - * List of validations for the field - * - * @default [] - */ - validation?: CmsModelFieldValidation[]; - /** - * List of validations for the list of values, when a field is set to accept a list of values. - * These validations will be applied to the entire list, and `validation` (see above) will be applied - * to each individual value in the list. - * - * @default [] - */ - listValidation?: CmsModelFieldValidation[]; - /** - * Is this a multiple values field? - * - */ - multipleValues?: boolean; - /** - * Fields can be tagged to give them contextual meaning. - */ - tags?: string[]; - /** - * Any user defined settings. - * - * @default {} - */ - settings?: CmsModelFieldSettings; -} - -export interface CmsDynamicZoneTemplate { - id: string; - name: string; - gqlTypeName: string; - description: string; - icon: string; - fields: CmsModelField[]; - layout: string[][]; - validation: CmsModelFieldValidation[]; - tags?: string[]; -} - -/** - * A definition for dynamic-zone field to show possible type of the field in settings. - */ -export interface CmsModelDynamicZoneField extends CmsModelField { - /** - * Settings object for the field. Contains `templates` property. - */ - settings: { - templates: CmsDynamicZoneTemplate[]; - }; -} - /** * Used for our internal functionality. */ @@ -335,241 +129,6 @@ export interface CmsModelFieldValidatorValidateParams { entry?: CmsEntry; } -/** - * Definition for the field validator. - * - * @category Plugin - * @category ModelField - * @category FieldValidation - */ -export interface CmsModelFieldValidatorPluginValidateCb { - (params: CmsModelFieldValidatorValidateParams): Promise; -} - -export interface CmsModelFieldValidatorPlugin extends Plugin { - /** - * A plugin type. - */ - type: "cms-model-field-validator"; - /** - * Actual validator definition. - */ - validator: { - /** - * Name of the validator. - */ - name: string; - /** - * Validation method. - */ - validate: CmsModelFieldValidatorPluginValidateCb; - }; -} - -/** - * A pattern validator for the content entry field value. - * - * @category Plugin - * @category ModelField - * @category FieldValidation - */ -export interface CmsModelFieldPatternValidatorPlugin extends Plugin { - /** - * A plugin type - */ - type: "cms-model-field-validator-pattern"; - /** - * A pattern object for the validator. - */ - pattern: { - /** - * name of the pattern. - */ - name: string; - /** - * RegExp of the validator. - */ - regex: string; - /** - * RegExp flags - */ - flags: string; - }; -} - -/** - * Locked field in the content model - * - * @see CmsModel.lockedFields - * - * @category ModelField - */ -export interface LockedField { - /** - * Locked field storage ID - one used to store values. - * We cannot change this due to old systems. - */ - fieldId: string; - /** - * Is the field multiple values field? - */ - multipleValues: boolean; - /** - * Field type. - */ - type: string; - - [key: string]: any; -} - -/** - * @category Database model - * @category CmsModel - */ -export interface CmsModelGroup { - /** - * Generated ID of the group - */ - id: string; - /** - * Name of the group - */ - name: string; -} - -export interface CmsModelAuthorization { - permissions: boolean; - - [key: string]: any; -} - -/** - * Base CMS Model. Should not be exported and used outside of this package. - * - * @category Database model - * @category CmsModel - */ -export interface CmsModel { - /** - * Name of the content model. - */ - name: string; - /** - * Unique ID for the content model. Created from name if not defined by user. - */ - modelId: string; - /** - * Name of the content model in singular form to be used in the API. - * example: - * - Article - * - Fruit - * - Vegetable - * - Car - */ - singularApiName: string; - /** - * Name of the content model in plural form to be used in the API. - * example: - * - Articles - * - Fruits - * - Vegetables - * - Cars - */ - pluralApiName: string; - /** - * Model tenant. - */ - tenant: string; - /** - * Locale this model belongs to. - */ - locale: string; - /** - * Cms Group reference object. - */ - group: CmsModelGroup; - /** - * Icon for the content model. - */ - icon?: string | null; - /** - * Description for the content model. - */ - description: string | null; - /** - * Date created - */ - createdOn?: string; - /** - * Date saved. Changes on both save and create. - */ - savedOn?: string; - /** - * CreatedBy object wrapper. Contains id, name and type of the user. - */ - createdBy?: CmsIdentity; - /** - * List of fields defining entry values. - */ - fields: CmsModelField[]; - /** - * Admin UI field layout - * - * ```ts - * layout: [ - * [field1id, field2id], - * [field3id] - * ] - * ``` - */ - layout: string[][]; - /** - * Models can be tagged to give them contextual meaning. - */ - tags?: string[]; - /** - * List of locked fields. Updated when entry is saved and a field has been used. - */ - lockedFields?: LockedField[]; - /** - * The field that is being displayed as entry title. - * It is picked as first available text field. Or user can select own field. - */ - titleFieldId: string; - /** - * The field which is displayed as the description one. - * Only way this is null or undefined is that there are no long-text fields to be set as description. - */ - descriptionFieldId?: string | null; - /** - * The field which is displayed as the image. - * Only way this is null or undefined is that there are no file fields, with images only set, to be set as image. - */ - imageFieldId?: string | null; - /** - * The version of Webiny which this record was stored with. - */ - webinyVersion: string; - - /** - * Is model private? - * This is meant to be used for some internal models - will not be visible in the schema. - * Only available for the plugin constructed models. - */ - isPrivate?: boolean; - - /** - * Does this model require authorization to be performed? - * Only available for models created via plugins. - */ - authorization?: boolean | CmsModelAuthorization; - - /** - * Is this model created via plugin? - */ - isPlugin?: boolean; -} - /** * When sending model to the storage operations, it must contain createValueKeyToStorageConverter and createValueKeyFromStorageConverter * @@ -620,303 +179,16 @@ export interface CmsModelFieldToGraphQLPluginValidateChildFieldsValidate { export interface CmsModelFieldToGraphQLPluginValidateChildFieldsParams< TField extends CmsModelField = CmsModelField -> { - field: TField; - originalField?: TField; - validate: CmsModelFieldToGraphQLPluginValidateChildFieldsValidate; -} - -export interface CmsModelFieldToGraphQLPluginValidateChildFields< - TField extends CmsModelField = CmsModelField -> { - (params: CmsModelFieldToGraphQLPluginValidateChildFieldsParams): void; -} - -/** - * @category Plugin - * @category ModelField - * @category GraphQL - */ -export interface CmsModelFieldToGraphQLPlugin - extends Plugin { - /** - * A plugin type - */ - type: "cms-model-field-to-graphql"; - /** - * Field type name which must be exact as the one in `CmsEditorFieldTypePlugin` plugin. - * - * ```ts - * fieldType: "myField" - * ``` - */ - fieldType: CmsModelFieldType; - /** - * Is the field searchable via the GraphQL? - * - * ```ts - * isSearchable: false - * ``` - */ - isSearchable: boolean; - /** - * Is the field searchable via full text search? - * - * Field is not full text searchable by default. - * ```ts - * fullTextSearch: false - * ``` - */ - fullTextSearch?: boolean; - /** - * Is the field sortable via the GraphQL? - * - * ```ts - * isSortable: true - * ``` - */ - isSortable: boolean; - /** - * Optional method which creates the storageId. - * Primary use is for the datetime field, but if users has some specific fields, they can customize the storageId to their needs. - * - * ```ts - * createStorageId: ({field}) => { - * if (field.settings.type === "time) { - * return `${field.type}_time@${field.id}` - * } - * // use default method - * return undefined; - * } - * ``` - */ - createStorageId?: (params: { - model: CmsModel; - field: Omit & Partial>; - }) => string | null | undefined; - /** - * Read API methods. - */ - read: { - /** - * Definition for get filtering for GraphQL. - * - * ```ts - * read: { - * createGetFilters({ field }) { - * return `${field.fieldId}: MyField`; - * } - * } - * ``` - */ - createGetFilters?(params: { field: TField }): string; - /** - * Definition for list filtering for GraphQL. - * - * ```ts - * read: { - * createListFilters({ field }) { - * return ` - * ${field.fieldId}: MyType - * ${field.fieldId}_not: MyType - * ${field.fieldId}_in: [MyType] - * ${field.fieldId}_not_in: [MyType] - * `; - * } - * } - * ``` - */ - createListFilters?(params: { - model: Pick; - field: TField; - plugins: CmsFieldTypePlugins; - }): string; - /** - * Definition of the field type for GraphQL - be aware if multiple values is selected. - * - * ```ts - * read: { - * createTypeField({ field }) { - * if (field.multipleValues) { - * return `${field.fieldId}: [MyFieldType]`; - * } - * - * return `${field.fieldId}: MyField`; - * } - * } - * ``` - */ - createTypeField(params: { - models: CmsModel[]; - model: CmsModel; - field: TField; - fieldTypePlugins: CmsFieldTypePlugins; - }): CmsModelFieldDefinition | string | null; - /** - * Definition for field resolver. - * By default, it is simple return of the `instance.values[storageId]` but if required, users can define their own. - * - * ```ts - * read: { - * createResolver({ field }) { - * return instance => { - * return instance.values[field.storageId]; - * }; - * } - * } - * ``` - */ - createResolver?: CmsModelFieldToGraphQLCreateResolver; - /** - * Read API schema definitions for the field and resolvers for them. - * - * ```ts - * read: { - * createSchema() { - * return { - * typeDefs: ` - * myField { - * id - * date - * } - * `, - * resolvers: {} - * } - * } - * } - * ``` - */ - createSchema?: (params: { models: CmsModel[] }) => GraphQLSchemaDefinition; - }; - manage: { - /** - * Definition for list filtering for GraphQL. - * - * ```ts - * manage: { - * createListFilters({ field }) { - * return ` - * ${field.fieldId}: MyType - * ${field.fieldId}_not: MyType - * ${field.fieldId}_in: [MyType] - * ${field.fieldId}_not_in: [MyType] - * `; - * } - * } - * ``` - */ - createListFilters?: (params: { - model: Pick; - field: TField; - plugins: CmsFieldTypePlugins; - }) => string; - /** - * Manage API schema definitions for the field and resolvers for them. Probably similar to `read.createSchema`. - * - * ```ts - * createSchema() { - * return { - * typeDefs: ` - * myField { - * id - * date - * } - * `, - * resolvers: {} - * } - * } - * ``` - */ - createSchema?: (params: { models: CmsModel[] }) => GraphQLSchemaDefinition; - /** - * Definition of the field type for GraphQL - be aware if multiple values is selected. Probably same as `read.createTypeField`. - * - * ```ts - * manage: { - * createTypeField({ field }) { - * if (field.multipleValues) { - * return field.fieldId + ": [MyType]"; - * } - * - * return field.fieldId + ": MyType"; - * } - * } - * ``` - */ - createTypeField: (params: { - models: CmsModel[]; - model: CmsModel; - field: TField; - fieldTypePlugins: CmsFieldTypePlugins; - }) => CmsModelFieldDefinition | string | null; - /** - * Definition for input GraphQL field type. - * - * ```ts - * manage: { - * createInputField({ field }) { - * if (field.multipleValues) { - * return field.fieldId + ": [MyField]"; - * } - * - * return field.fieldId + ": MyField"; - * } - * } - * ``` - */ - createInputField: (params: { - models: CmsModel[]; - model: CmsModel; - field: TField; - fieldTypePlugins: CmsFieldTypePlugins; - }) => CmsModelFieldDefinition | string | null; - /** - * Definition for field resolver. - * By default, it is simple return of the `instance.values[storageId]` but if required, users can define their own. - * - * ```ts - * manage: { - * createResolver({ field }) { - * return instance => { - * return instance.values[field.storageId]; - * }; - * } - * } - * ``` - */ - createResolver?: CmsModelFieldToGraphQLCreateResolver; - }; - /** - * - * @param field - */ - validateChildFields?: CmsModelFieldToGraphQLPluginValidateChildFields; -} - -/** - * Check for content model locked field. - * A custom plugin definable by the user. - * - * @category CmsModel - * @category Plugin - */ -export interface CmsModelLockedFieldPlugin extends Plugin { - /** - * A plugin type - */ - type: "cms-model-locked-field"; - /** - * A unique identifier of the field type (text, number, json, myField, ...). - */ - fieldType: string; - /** - * A method to check if field really is locked. - */ - checkLockedField?: (params: { lockedField: LockedField; field: CmsModelField }) => void; - /** - * A method to get the locked field data. - */ - getLockedFieldData?: (params: { field: CmsModelField }) => Record; +> { + field: TField; + originalField?: TField; + validate: CmsModelFieldToGraphQLPluginValidateChildFieldsValidate; +} + +export interface CmsModelFieldToGraphQLPluginValidateChildFields< + TField extends CmsModelField = CmsModelField +> { + (params: CmsModelFieldToGraphQLPluginValidateChildFieldsParams): void; } /** @@ -926,26 +198,6 @@ export interface CmsFieldTypePlugins { [key: string]: CmsModelFieldToGraphQLPlugin; } -/** - * An interface describing the reference to a user that created some data in the database. - * - * @category General - */ -export interface CmsIdentity { - /** - * ID if the user. - */ - id: string; - /** - * Full name of the user. - */ - displayName: string | null; - /** - * Type of the user (admin, user) - */ - type: string; -} - export interface OnSystemBeforeInstallTopicParams { tenant: string; locale: string; @@ -1001,69 +253,6 @@ export interface CmsGroupUpdateInput { icon?: string; } -/** - * A representation of content model group in the database. - * - * @category CmsGroup - * @category Database model - */ -export interface CmsGroup { - /** - * Generated ID. - */ - id: string; - /** - * Name of the group. - */ - name: string; - /** - * Slug for the group. Must be unique. - */ - slug: string; - /** - * Group tenant. - */ - tenant: string; - /** - * Locale this group belongs to. - */ - locale: string; - /** - * Description for the group. - */ - description: string | null; - /** - * Icon for the group. In a form of "ico/ico". - */ - icon: string; - /** - * CreatedBy reference object. - */ - createdBy?: CmsIdentity; - /** - * Date group was created on. - */ - createdOn?: string; - /** - * Date group was created or changed on. - */ - savedOn?: string; - /** - * Which Webiny version was this record stored with. - */ - webinyVersion: string; - /** - * Is group private? - * This is meant to be used for some internal groups - will not be visible in the schema. - * Only available for the plugin constructed groups. - */ - isPrivate?: boolean; - /** - * Is this group created via plugin? - */ - isPlugin?: boolean; -} - /** * A `data.where` parameter received when listing content model groups. * @@ -1202,209 +391,6 @@ export interface CmsGroupContext { onGroupDeleteError: Topic; } -/** - * Definition for content model field validator. - * - * @category ModelField - * @category FieldValidation - */ -export interface CmsModelFieldValidation { - name: string; - message: string; - settings?: { - value?: string | number; - values?: string[]; - preset?: string; - [key: string]: any; - }; -} - -/** - * A GraphQL `params.data` parameter received when creating content model. - * - * @category GraphQL params - * @category CmsModel - */ -export interface CmsModelCreateInput { - /** - * Name of the content model. - */ - name: string; - /** - * Singular name of the content model to be used in the API. - */ - singularApiName: string; - /** - * Plural name of the content model to be used in the API. - */ - pluralApiName: string; - /** - * Unique ID of the content model. Created from name if not sent by the user. Cannot be changed. - */ - modelId?: string; - /** - * Description of the content model. - */ - description?: string | null; - /** - * Group where to put the content model in. - */ - group: string; - /** - * A list of content model fields to define the entry values. - */ - fields?: CmsModelFieldInput[]; - /** - * Admin UI field layout - * - * ```ts - * layout: [ - * [field1id, field2id], - * [field3id] - * ] - * ``` - */ - layout?: string[][]; - /** - * Models can be tagged to give them contextual meaning. - */ - tags?: string[]; - /** - * Fields fieldId which are picked to represent the CMS entry. - */ - titleFieldId?: string | null; - descriptionFieldId?: string | null; - imageFieldId?: string | null; -} - -/** - * A GraphQL `params.data` parameter received when creating content model from existing model. - * - * @category GraphQL params - * @category CmsModel - */ -export interface CmsModelCreateFromInput extends CmsModelCreateInput { - /** - * Locale into which we want to clone the model into. - */ - locale?: string; -} - -/** - * A definition for content model field received from the user. - * - * Input type for `CmsModelField`. - * @see CmsModelField - * - * @category GraphQL params - * @category ModelField - */ -export interface CmsModelFieldInput { - /** - * Generated ID. - */ - id: string; - /** - * Type of the field. A plugin for the field must be defined. - * @see CmsModelFieldToGraphQLPlugin - */ - type: string; - /** - * Field outside world identifier for the field. Must be unique in the model. - */ - fieldId: string; - /** - * Label for the field. - */ - label: string; - /** - * Text to display below the field to help user what to write in the field. - */ - helpText?: string | null; - /** - * Text to display in the field. - */ - placeholderText?: string | null; - /** - * Fields can be tagged to give them contextual meaning. - */ - tags?: string[]; - /** - * Are multiple values allowed? - */ - multipleValues?: boolean; - /** - * Predefined values options for the field. Check the reference for more information. - */ - predefinedValues?: CmsModelFieldPredefinedValues; - /** - * Renderer options for the field. Check the reference for more information. - */ - renderer?: CmsModelFieldRenderer; - /** - * List of validations for the field. - */ - validation?: CmsModelFieldValidation[]; - /** - * @see CmsModelField.listValidation - */ - listValidation?: CmsModelFieldValidation[]; - /** - * User defined settings. - */ - settings?: Record; -} - -/** - * A GraphQL `params.data` parameter received when updating content model. - * - * @category GraphQL params - * @category CmsModel - */ -export interface CmsModelUpdateInput { - /** - * A new content model name. - */ - name?: string; - /** - * A new singular name of the content model to be used in the API. - */ - singularApiName?: string; - /** - * A new plural name of the content model to be used in the API. - */ - pluralApiName?: string; - /** - * A group we want to move the model to. - */ - group?: string; - /** - * A new description of the content model. - */ - description?: string | null; - /** - * A list of content model fields to define the entry values. - */ - fields: CmsModelFieldInput[]; - /** - * Admin UI field layout - * - * ```ts - * layout: [ - * [field1id, field2id], - * [field3id] - * ] - * ``` - */ - layout: string[][]; - /** - * Fields fieldId which are picked to represent the CMS entry. - */ - titleFieldId?: string | null; - descriptionFieldId?: string | null; - imageFieldId?: string | null; -} - /** * A plugin to load a CmsModelManager. * @@ -1782,6 +768,10 @@ export interface CmsModelContext { * Get a single content model. */ getModel: (modelId: string) => Promise; + /** + * Get model to AST converter. + */ + getModelToAstConverter: () => CmsModelToAstConverter; /** * Get all content models. */ @@ -2460,184 +1450,6 @@ export interface CmsEntryValidateResponse { [key: string]: any; } -/** - * Cms Entry CRUD methods in the context. - * - * @category Context - * @category CmsEntry - */ -export interface CmsEntryContext { - /** - * Get a single content entry for a model. - */ - getEntry: (model: CmsModel, params: CmsEntryGetParams) => Promise; - /** - * Get a list of entries for a model by a given ID (revision). - */ - getEntriesByIds: (model: CmsModel, revisions: string[]) => Promise; - /** - * Get the entry for a model by a given ID. - */ - getEntryById: (model: CmsModel, revision: string) => Promise; - /** - * List entries for a model. Internal method used by get, listLatest and listPublished. - */ - listEntries: ( - model: CmsModel, - params: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; - /** - * Lists the latest entries. Used for manage API. - */ - listLatestEntries: ( - model: CmsModel, - params?: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; - /** - * List published entries. Used for read API. - */ - listPublishedEntries: ( - model: CmsModel, - params?: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; - /** - * Lists the deleted entries. Used for manage API. - */ - listDeletedEntries: ( - model: CmsModel, - params?: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; - /** - * List published entries by IDs. - */ - getPublishedEntriesByIds: (model: CmsModel, ids: string[]) => Promise; - /** - * List latest entries by IDs. - */ - getLatestEntriesByIds: (model: CmsModel, ids: string[]) => Promise; - /** - * Create a new content entry. - */ - createEntry: ( - model: CmsModel, - input: CreateCmsEntryInput, - options?: CreateCmsEntryOptionsInput - ) => Promise; - /** - * Create a new entry from already existing entry. - */ - createEntryRevisionFrom: ( - model: CmsModel, - id: string, - input: CreateFromCmsEntryInput, - options?: CreateRevisionCmsEntryOptionsInput - ) => Promise; - /** - * Update existing entry. - */ - updateEntry: ( - model: CmsModel, - id: string, - input: UpdateCmsEntryInput, - meta?: Record, - options?: UpdateCmsEntryOptionsInput - ) => Promise; - /** - * Validate the entry - either new one or existing one. - */ - validateEntry: ( - model: CmsModel, - id?: string, - input?: UpdateCmsEntryInput - ) => Promise; - /** - * Move entry, and all its revisions, to a new folder. - */ - moveEntry: (model: CmsModel, id: string, folderId: string) => Promise; - /** - * Method that republishes entry with given identifier. - * @internal - */ - republishEntry: (model: CmsModel, id: string) => Promise; - /** - * Delete only a certain revision of the entry. - */ - deleteEntryRevision: (model: CmsModel, id: string) => Promise; - /** - * Delete entry with all its revisions. - */ - deleteEntry: (model: CmsModel, id: string, options?: CmsDeleteEntryOptions) => Promise; - /** - * Delete multiple entries - */ - deleteMultipleEntries: ( - model: CmsModel, - params: DeleteMultipleEntriesParams - ) => Promise; - /** - * Publish entry. - */ - publishEntry: (model: CmsModel, id: string) => Promise; - /** - * Unpublish entry. - */ - unpublishEntry: (model: CmsModel, id: string) => Promise; - /** - * Get all entry revisions. - */ - getEntryRevisions: (model: CmsModel, id: string) => Promise; - /** - * List all unique values for a given field. - * - * @internal - */ - getUniqueFieldValues: ( - model: CmsModel, - params: GetUniqueFieldValuesParams - ) => Promise; - /** - * Lifecycle Events - */ - onEntryBeforeCreate: Topic; - onEntryAfterCreate: Topic; - onEntryCreateError: Topic; - - onEntryRevisionBeforeCreate: Topic; - onEntryRevisionAfterCreate: Topic; - onEntryRevisionCreateError: Topic; - - onEntryBeforeUpdate: Topic; - onEntryAfterUpdate: Topic; - onEntryUpdateError: Topic; - - onEntryBeforeMove: Topic; - onEntryAfterMove: Topic; - onEntryMoveError: Topic; - - onEntryBeforeDelete: Topic; - onEntryAfterDelete: Topic; - onEntryDeleteError: Topic; - - onEntryRevisionBeforeDelete: Topic; - onEntryRevisionAfterDelete: Topic; - onEntryRevisionDeleteError: Topic; - - onEntryBeforePublish: Topic; - onEntryAfterPublish: Topic; - onEntryPublishError: Topic; - - onEntryBeforeRepublish: Topic; - onEntryAfterRepublish: Topic; - onEntryRepublishError: Topic; - - onEntryBeforeUnpublish: Topic; - onEntryAfterUnpublish: Topic; - onEntryUnpublishError: Topic; - - onEntryBeforeGet: Topic; - onEntryBeforeList: Topic; -} - /** * Parameters for CmsEntryResolverFactory. * diff --git a/packages/api-headless-cms/src/utils/contentEntryTraverser/ContentEntryTraverser.ts b/packages/api-headless-cms/src/utils/contentEntryTraverser/ContentEntryTraverser.ts new file mode 100644 index 00000000000..dad198f34b9 --- /dev/null +++ b/packages/api-headless-cms/src/utils/contentEntryTraverser/ContentEntryTraverser.ts @@ -0,0 +1,126 @@ +import { + CmsModelAst, + CmsModelFieldAstNodeField, + CmsModelFieldAstNode, + ContentEntryValueVisitor, + CmsModelFieldAstNodeCollection, + CmsEntryValues +} from "~/types"; + +type ParentNode = CmsModelAst | CmsModelFieldAstNode | null; + +type VisitorContext = { + node: CmsModelFieldAstNode; + parent: ParentNode; +}; + +const nodeHasChildren = (node: CmsModelFieldAstNode) => { + return node.children.length > 0; +}; + +interface NodeWithCollections extends CmsModelFieldAstNodeField { + children: CmsModelFieldAstNodeCollection[]; +} + +const childrenAreCollections = (node: CmsModelFieldAstNode): node is NodeWithCollections => { + return node.children.every(node => node.type === "collection"); +}; + +export class ContentEntryTraverser { + private readonly modelAst: CmsModelAst; + + constructor(modelAst: CmsModelAst) { + this.modelAst = modelAst; + } + + traverse(values: CmsEntryValues, visitor: ContentEntryValueVisitor) { + this.visitTree(this.modelAst, values, [], visitor); + } + + private visitTree( + root: CmsModelAst | CmsModelFieldAstNode, + values: CmsEntryValues, + path: string[], + visitor: ContentEntryValueVisitor + ) { + for (const node of root.children) { + const context: VisitorContext = { node, parent: root }; + const field = this.getFieldFromNode(context); + const value = values[field.fieldId]; + + if (!value) { + continue; + } + + const fieldPath = [...path, field.fieldId]; + + visitor( + { + field, + value, + path: fieldPath.join(".") + }, + context + ); + + if (nodeHasChildren(node) && childrenAreCollections(node)) { + if (field.multipleValues) { + this.ensureArray(value).forEach((value, index) => { + this.findCollectionAndVisit( + node, + value, + [...fieldPath, index.toString()], + visitor + ); + }); + } else { + this.findCollectionAndVisit(node, value, path, visitor); + } + continue; + } + + if (field.multipleValues) { + this.ensureArray(value).forEach((value, index) => { + this.visitTree(node, value, [...fieldPath, index.toString()], visitor); + }); + continue; + } + + this.visitTree(node, value, fieldPath, visitor); + } + } + + private ensureArray(value: any) { + if (!Array.isArray(value)) { + return []; + } + + return value; + } + + private findCollectionAndVisit( + node: NodeWithCollections, + values: CmsEntryValues, + path: string[], + visitor: ContentEntryValueVisitor + ) { + const collection = node.children.find(child => { + // Use the `discriminator` to find the correct value. + return values[child.collection.discriminator] === child.collection.id; + }); + + if (!collection) { + return; + } + + this.visitTree(collection, values, path, visitor); + } + + private getFieldFromNode({ node, parent }: VisitorContext) { + if (node.type === "collection") { + return (parent as CmsModelFieldAstNodeField).field; + } + + return (node as CmsModelFieldAstNodeField).field; + } +} diff --git a/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstConverterFromPlugins.ts b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstConverterFromPlugins.ts new file mode 100644 index 00000000000..3338f87d4b4 --- /dev/null +++ b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstConverterFromPlugins.ts @@ -0,0 +1,21 @@ +import { CmsModelField, CmsModelFieldToGraphQLPlugin, ICmsModelFieldToAst } from "~/types"; +import { CmsModelFieldToAstFromPlugin } from "./CmsModelFieldToAstFromPlugin"; + +type FieldToAstConverters = Record; + +export class CmsModelFieldToAstConverterFromPlugins implements ICmsModelFieldToAst { + private converters: FieldToAstConverters; + + constructor(plugins: CmsModelFieldToGraphQLPlugin[]) { + this.converters = plugins.reduce((converters, plugin) => { + return { + ...converters, + [plugin.fieldType]: new CmsModelFieldToAstFromPlugin(plugin, this) + }; + }, {}); + } + + toAst(field: CmsModelField) { + return this.converters[field.type].toAst(field); + } +} diff --git a/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstFromPlugin.ts b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstFromPlugin.ts new file mode 100644 index 00000000000..e0af06cf0ad --- /dev/null +++ b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelFieldToAstFromPlugin.ts @@ -0,0 +1,22 @@ +import { + CmsModelField, + CmsModelFieldAstNode, + CmsModelFieldToGraphQLPlugin, + ICmsModelFieldToAst +} from "~/types"; + +export class CmsModelFieldToAstFromPlugin implements ICmsModelFieldToAst { + private readonly converter: ICmsModelFieldToAst; + private plugin: CmsModelFieldToGraphQLPlugin; + + constructor(plugin: CmsModelFieldToGraphQLPlugin, converter: ICmsModelFieldToAst) { + this.converter = converter; + this.plugin = plugin; + } + + toAst(field: CmsModelField): CmsModelFieldAstNode { + return this.plugin.getFieldAst + ? this.plugin.getFieldAst(field, this.converter) + : { type: "field", field, children: [] }; + } +} diff --git a/packages/api-headless-cms/src/utils/contentModelAst/CmsModelToAstConverter.ts b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelToAstConverter.ts new file mode 100644 index 00000000000..6d4203f5f63 --- /dev/null +++ b/packages/api-headless-cms/src/utils/contentModelAst/CmsModelToAstConverter.ts @@ -0,0 +1,24 @@ +import { CmsModel, CmsModelAst, CmsModelFieldAstNode, ICmsModelFieldToAst } from "~/types"; + +/** + * The purpose of this class is to convert the given CmsModel to an AST, which is easier to traverse programmatically. + * Using this, we can get information about the structure of the model fields, without understanding how each + * individual model field stores its internal data. This is particularly useful for fields like `dynamicZone` and `object`. + */ + +export class CmsModelToAstConverter { + private readonly fieldToAstConverter: ICmsModelFieldToAst; + + constructor(fieldToAstConverter: ICmsModelFieldToAst) { + this.fieldToAstConverter = fieldToAstConverter; + } + + toAst(model: CmsModel): CmsModelAst { + return { + type: "root", + children: model.fields.reduce((ast, field) => { + return [...ast, this.fieldToAstConverter.toAst(field)]; + }, []) + }; + } +} diff --git a/packages/api-headless-cms/src/utils/contentModelAst/index.ts b/packages/api-headless-cms/src/utils/contentModelAst/index.ts new file mode 100644 index 00000000000..fd996118652 --- /dev/null +++ b/packages/api-headless-cms/src/utils/contentModelAst/index.ts @@ -0,0 +1,3 @@ +export * from "./CmsModelFieldToAstFromPlugin"; +export * from "./CmsModelFieldToAstConverterFromPlugins"; +export * from "./CmsModelToAstConverter";