diff --git a/apps/website/package.json b/apps/website/package.json index e5aae584a05..fc9475c3af2 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -12,14 +12,12 @@ "@webiny/cli": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/project-utils": "0.0.0", - "@webiny/react-router": "0.0.0", "@webiny/serverless-cms-aws": "0.0.0", "apollo-client": "^2.6.10", "apollo-link": "^1.2.14", "core-js": "^3.0.1", "cross-fetch": "^3.0.4", "graphql": "^15.7.2", - "graphql-tag": "^2.12.6", "react": "18.2.0", "react-dom": "18.2.0", "regenerator-runtime": "^0.13.5", diff --git a/apps/website/src/plugins/linkPreload.ts b/apps/website/src/plugins/linkPreload.ts deleted file mode 100644 index 9d995361321..00000000000 --- a/apps/website/src/plugins/linkPreload.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ReactRouterOnLinkPlugin } from "@webiny/react-router/types"; -import gql from "graphql-tag"; -import { isPrerendering, getPrerenderId } from "@webiny/app-website"; -import { GET_PUBLISHED_PAGE } from "@webiny/app-website/Page/graphql"; - -export default (): ReactRouterOnLinkPlugin => { - const preloadedPaths: string[] = []; - - return { - name: "react-router-on-link-pb", - type: "react-router-on-link", - async onLink({ link: path, apolloClient }) { - // Only if we're serving a pre-rendered page, we want to activate this feature. - if (isPrerendering()) { - return; - } - - if ( - typeof path !== "string" || - !path.startsWith("/") || - preloadedPaths.includes(path) - ) { - return; - } - - preloadedPaths.push(path); - - const graphqlJson = `graphql.json?k=${getPrerenderId()}`; - const fetchPath = path !== "/" ? `${path}/${graphqlJson}` : `/${graphqlJson}`; - const pageState = await fetch(fetchPath) - .then(res => res.json()) - .catch(() => null); - - if (pageState) { - for (let i = 0; i < pageState.length; i++) { - const { query, variables, data } = pageState[i]; - apolloClient.writeQuery({ - query: gql` - ${query} - `, - data, - variables - }); - } - } else { - apolloClient.query({ - query: GET_PUBLISHED_PAGE(), - variables: { - id: null, - path, - preview: false, - returnErrorPage: true, - returnNotFoundPage: true - } - }); - } - } - }; -}; diff --git a/apps/website/src/plugins/pageBuilder.ts b/apps/website/src/plugins/pageBuilder.ts index 60ad25ac2f8..84a1bbc39d7 100644 --- a/apps/website/src/plugins/pageBuilder.ts +++ b/apps/website/src/plugins/pageBuilder.ts @@ -1,7 +1,6 @@ /** * Plugins specific to the "website" app. */ -import linkPreload from "./linkPreload"; /** * Ensures GraphQL's PbPage and PbPageListItem types are correctly cached. @@ -66,7 +65,6 @@ import accordionItemSettings from "@webiny/app-page-builder/editor/plugins/eleme import responsiveMode from "@webiny/app-page-builder/render/plugins/responsiveMode"; export default [ - linkPreload(), apolloCacheObjectId, // Page elements diff --git a/packages/api-headless-cms-ddb-es/src/index.ts b/packages/api-headless-cms-ddb-es/src/index.ts index dbd1794db70..b4bf759c8c1 100644 --- a/packages/api-headless-cms-ddb-es/src/index.ts +++ b/packages/api-headless-cms-ddb-es/src/index.ts @@ -33,7 +33,7 @@ import { } from "~/plugins"; import { createFilterPlugins } from "~/operations/entry/elasticsearch/filtering/plugins"; import { CmsEntryFilterPlugin } from "~/plugins/CmsEntryFilterPlugin"; -import { StorageOperationsCmsModelPlugin } from "@webiny/api-headless-cms"; +import { StorageOperationsCmsModelPlugin, StorageTransformPlugin } from "@webiny/api-headless-cms"; import { createElasticsearchIndexesOnLocaleAfterCreate } from "~/operations/system/indexes"; import { createIndexTaskPluginTest } from "~/tasks/createIndexTaskPlugin"; @@ -157,6 +157,7 @@ export const createStorageOperations: StorageOperationsFactory = params => { CmsEntryElasticsearchSortModifierPlugin.type, CmsElasticsearchModelFieldPlugin.type, StorageOperationsCmsModelPlugin.type, + StorageTransformPlugin.type, CmsEntryElasticsearchValuesModifier.type ]; for (const type of types) { diff --git a/packages/api-headless-cms-ddb/src/index.ts b/packages/api-headless-cms-ddb/src/index.ts index 5f21ae98e6e..bc99f1ac55f 100644 --- a/packages/api-headless-cms-ddb/src/index.ts +++ b/packages/api-headless-cms-ddb/src/index.ts @@ -20,7 +20,7 @@ import { CmsFieldFilterValueTransformPlugin } from "~/plugins"; import { ValueFilterPlugin } from "@webiny/db-dynamodb/plugins/definitions/ValueFilterPlugin"; -import { StorageOperationsCmsModelPlugin } from "@webiny/api-headless-cms"; +import { StorageOperationsCmsModelPlugin, StorageTransformPlugin } from "@webiny/api-headless-cms"; export * from "./plugins"; @@ -89,7 +89,8 @@ export const createStorageOperations: StorageOperationsFactory = params => { CmsEntryFieldFilterPlugin.type, CmsEntryFieldSortingPlugin.type, ValueFilterPlugin.type, - StorageOperationsCmsModelPlugin.type + StorageOperationsCmsModelPlugin.type, + StorageTransformPlugin.type ]; /** * Collect all required plugins from parent context. diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts index 713ba171e10..9c5f84e5b90 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -116,16 +116,17 @@ export const createEntriesStorageOperations = ( entity }); - const storageTransformPlugins = plugins - .byType(StorageTransformPlugin.type) - .reduce((collection, plugin) => { - collection[plugin.fieldType] = plugin; - return collection; - }, {} as Record); - const createStorageTransformCallable = ( model: StorageOperationsCmsModel ): FilterItemFromStorage => { + // Cache StorageTransformPlugin to optimize execution. + const storageTransformPlugins = plugins + .byType(StorageTransformPlugin.type) + .reduce((collection, plugin) => { + collection[plugin.fieldType] = plugin; + return collection; + }, {} as Record); + return (field, value) => { const plugin: StorageTransformPlugin = storageTransformPlugins[field.type]; if (!plugin) { diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 54b4e557029..08548820037 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -5,12 +5,15 @@ import { CmsContext, CmsEntry, CmsEntryContext, + CmsEntryGetParams, CmsEntryListParams, CmsEntryListWhere, CmsEntryMeta, CmsEntryValues, CmsModel, CmsStorageEntry, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput, EntryBeforeListTopicParams, HeadlessCmsStorageOperations, OnEntryAfterCreateTopicParams, @@ -378,7 +381,11 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm } return entry; }; - const createEntry: CmsEntryContext["createEntry"] = async (model, rawInput, options) => { + const createEntry: CmsEntryContext["createEntry"] = async ( + model: CmsModel, + rawInput: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise> => { await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); const { entry, input } = await createEntryData({ @@ -416,7 +423,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm input }); - return entry; + return entry as CmsEntry; } catch (ex) { await onEntryCreateError.publish({ error: ex, @@ -1304,9 +1311,12 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm /** * @internal */ - async getEntry(model, params) { + async getEntry( + model: CmsModel, + params: CmsEntryGetParams + ): Promise> { return context.benchmark.measure("headlessCms.crud.entries.getEntry", async () => { - return await getEntryUseCase.execute(model, params); + return (await getEntryUseCase.execute(model, params)) as CmsEntry; }); }, /** @@ -1355,7 +1365,11 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm } ); }, - async createEntry(model, input, options) { + async createEntry( + model: CmsModel, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise> { return context.benchmark.measure("headlessCms.crud.entries.createEntry", async () => { return createEntry(model, input, options); }); diff --git a/packages/api-headless-cms/src/types/context.ts b/packages/api-headless-cms/src/types/context.ts index 07d7ac34407..4ba674077b5 100644 --- a/packages/api-headless-cms/src/types/context.ts +++ b/packages/api-headless-cms/src/types/context.ts @@ -67,7 +67,10 @@ export interface CmsEntryContext { /** * Get a single content entry for a model. */ - getEntry: (model: CmsModel, params: CmsEntryGetParams) => Promise; + getEntry: ( + model: CmsModel, + params: CmsEntryGetParams + ) => Promise>; /** * Get a list of entries for a model by a given ID (revision). */ @@ -115,11 +118,11 @@ export interface CmsEntryContext { /** * Create a new content entry. */ - createEntry: ( + createEntry: ( model: CmsModel, - input: CreateCmsEntryInput, + input: CreateCmsEntryInput, options?: CreateCmsEntryOptionsInput - ) => Promise; + ) => Promise>; /** * Create a new entry from already existing entry. */ @@ -132,10 +135,10 @@ export interface CmsEntryContext { /** * Update existing entry. */ - updateEntry: ( + updateEntry: ( model: CmsModel, id: string, - input: UpdateCmsEntryInput, + input: UpdateCmsEntryInput, meta?: Record, options?: UpdateCmsEntryOptionsInput ) => Promise; diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 1ee778ca5b4..7736379b85f 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1405,7 +1405,7 @@ export interface EntryBeforeListTopicParams { * @category Context * @category CmsEntry */ -export interface CreateCmsEntryInput { +export type CreateCmsEntryInput = TValues & { id?: string; status?: CmsEntryStatus; @@ -1447,9 +1447,7 @@ export interface CreateCmsEntryInput { wbyAco_location?: { folderId?: string | null; }; - - [key: string]: any; -} +}; export interface CreateCmsEntryOptionsInput { skipValidators?: string[]; @@ -1499,7 +1497,7 @@ export interface CreateRevisionCmsEntryOptionsInput { * @category Context * @category CmsEntry */ -export interface UpdateCmsEntryInput { +export type UpdateCmsEntryInput = TValues & { /** * Revision-level meta fields. 👇 */ @@ -1539,9 +1537,7 @@ export interface UpdateCmsEntryInput { wbyAco_location?: { folderId?: string | null; }; - - [key: string]: any; -} +}; export interface UpdateCmsEntryOptionsInput { skipValidators?: string[]; diff --git a/packages/api-page-builder-import-export/src/graphql/graphql/importExportTasks.gql.ts b/packages/api-page-builder-import-export/src/graphql/graphql/importExportTasks.gql.ts index 0af3487612e..2ec8c769757 100644 --- a/packages/api-page-builder-import-export/src/graphql/graphql/importExportTasks.gql.ts +++ b/packages/api-page-builder-import-export/src/graphql/graphql/importExportTasks.gql.ts @@ -24,7 +24,7 @@ const plugin: GraphQLSchemaPlugin = { type PbImportExportTask { id: ID createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity status: PbImportExportTaskStatus data: JSON stats: PbImportExportTaskStats diff --git a/packages/api-page-builder-import-export/src/graphql/graphql/pages.gql.ts b/packages/api-page-builder-import-export/src/graphql/graphql/pages.gql.ts index 77bcf422300..ba527b572c2 100644 --- a/packages/api-page-builder-import-export/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder-import-export/src/graphql/graphql/pages.gql.ts @@ -34,7 +34,7 @@ const plugin: GraphQLSchemaPlugin = { type PbExportPagesTask { id: ID! createdOn: DateTime! - createdBy: PbCreatedBy! + createdBy: PbIdentity! status: PbImportExportPagesTaskStatus! data: PbExportPagesTaskData! stats: PbImportExportPagesTaskStats! @@ -54,7 +54,7 @@ const plugin: GraphQLSchemaPlugin = { type PbImportPagesTask { id: ID! createdOn: DateTime! - createdBy: PbCreatedBy! + createdBy: PbIdentity! status: PbImportExportPagesTaskStatus! stats: PbImportExportPagesTaskStats! data: PbImportExportPagesTaskData! diff --git a/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageBlockContent.ts b/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageBlockContent.ts deleted file mode 100644 index 0841930abea..00000000000 --- a/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageBlockContent.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { PageElementId } from "~/graphql"; - -const blockId = PageElementId.create().getValue(); - -/** - * Contains a grid > cell with a heading and a paragraph. - * The heading and paragraph are both editable (linked elements). - */ -export const simplePageBlockContent = { - id: blockId, - type: "block", - data: { - settings: { - width: { - desktop: { - value: "100%" - } - }, - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "10px" - } - }, - horizontalAlignFlex: { - desktop: "center" - }, - verticalAlign: { - desktop: "flex-start" - } - }, - variables: [ - { - id: "FDEezrJ8NH", - type: "heading", - label: "Heading text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "SezNLOdXw3", - type: "paragraph", - label: "Paragraph text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - ] - }, - elements: [ - { - id: PageElementId.create().getValue(), - type: "grid", - data: { - settings: { - width: { - desktop: { - value: "1100px" - } - }, - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "10px" - } - }, - grid: { - cellsType: "12" - }, - gridSettings: { - desktop: { - flexDirection: "row" - }, - "mobile-landscape": { - flexDirection: "column" - } - }, - horizontalAlignFlex: { - desktop: "flex-start" - }, - verticalAlign: { - desktop: "flex-start" - } - } - }, - elements: [ - { - id: PageElementId.create().getValue(), - type: "cell", - data: { - settings: { - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "0px" - } - }, - grid: { - size: 12 - } - } - }, - elements: [ - { - id: PageElementId.create().getValue(), - type: "heading", - data: { - text: { - desktop: { - type: "heading", - alignment: "left", - tag: "h1" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - }, - settings: { - margin: { - desktop: { - all: "0px" - } - }, - padding: { - desktop: { - all: "0px" - } - } - }, - variableId: "FDEezrJ8NH" - }, - elements: [], - path: ["UTaSFnVtkV", "uFzaV9SB6q", "k77Fdcod55", "BOMdKQBt23"] - }, - { - id: PageElementId.create().getValue(), - type: "paragraph", - data: { - text: { - desktop: { - type: "paragraph", - alignment: "left", - tag: "p" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - }, - settings: { - margin: { - desktop: { - all: "0px" - } - }, - padding: { - desktop: { - all: "0px" - } - } - }, - variableId: "SezNLOdXw3" - }, - elements: [], - path: ["UTaSFnVtkV", "uFzaV9SB6q", "k77Fdcod55", "BOMdKQBt23"] - } - ], - path: ["UTaSFnVtkV", "uFzaV9SB6q", "k77Fdcod55"] - } - ], - path: ["UTaSFnVtkV", "uFzaV9SB6q"] - } - ], - path: ["UTaSFnVtkV"] -}; diff --git a/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageTemplateContent.ts b/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageTemplateContent.ts deleted file mode 100644 index 100958dabf1..00000000000 --- a/packages/api-page-builder/__tests__/graphql/mocks/pageTemplates/simplePageTemplateContent.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Contains a grid > cell with a heading and a paragraph. - * The heading and paragraph are both editable (linked elements). - */ -export const simplePageTemplateContent = { - id: "lk860n5p", - type: "document", - data: { - template: { - variables: [ - { - blockId: "yAOxZQgZsv", - variables: [ - { - id: "aAUBVaa1fB", - type: "heading", - label: "Heading text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "iwpP2qZAHy", - type: "paragraph", - label: "Paragraph text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - ] - } - ] - } - }, - elements: [ - { - id: "yAOxZQgZsv", - type: "block", - data: { - templateBlockId: "yAOxZQgZsv", - settings: { - width: { - desktop: { - value: "100%" - } - }, - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "10px" - } - }, - horizontalAlignFlex: { - desktop: "center" - }, - verticalAlign: { - desktop: "flex-start" - } - }, - variables: [ - { - id: "aAUBVaa1fB", - type: "heading", - label: "Heading text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "iwpP2qZAHy", - type: "paragraph", - label: "Paragraph text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - ] - }, - elements: [ - { - id: "luzsb731h5", - type: "grid", - data: { - settings: { - width: { - desktop: { - value: "1100px" - } - }, - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "10px" - } - }, - grid: { - cellsType: "12" - }, - gridSettings: { - desktop: { - flexDirection: "row" - }, - "mobile-landscape": { - flexDirection: "column" - } - }, - horizontalAlignFlex: { - desktop: "flex-start" - }, - verticalAlign: { - desktop: "flex-start" - } - } - }, - elements: [ - { - id: "c1KzABC9LJ", - type: "cell", - data: { - settings: { - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "0px" - } - }, - grid: { - size: 12 - }, - horizontalAlignFlex: { - desktop: "flex-start" - } - } - }, - elements: [ - { - id: "aAUBVaa1fB", - type: "heading", - data: { - text: { - desktop: { - type: "heading", - alignment: "left", - tag: "h1" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Heading","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - }, - settings: { - margin: { - desktop: { - all: "0px" - } - }, - padding: { - desktop: { - all: "0px" - } - } - }, - variableId: "aAUBVaa1fB" - }, - elements: [], - path: ["lk860n5p", "yAOxZQgZsv", "luzsb731h5", "c1KzABC9LJ"] - }, - { - id: "iwpP2qZAHy", - type: "paragraph", - data: { - text: { - desktop: { - type: "paragraph", - alignment: "left", - tag: "p" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. Duis cursus, mi quis viverra ornare, eros dolor interdum nulla, ut commodo diam libero vitae erat. Aenean faucibus nibh et justo cursus id rutrum lorem imperdiet. Nunc ut sem vitae risus tristique posuere.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - }, - settings: { - margin: { - desktop: { - all: "0px" - } - }, - padding: { - desktop: { - all: "0px" - } - } - }, - variableId: "iwpP2qZAHy" - }, - elements: [], - path: ["lk860n5p", "yAOxZQgZsv", "luzsb731h5", "c1KzABC9LJ"] - } - ], - path: ["lk860n5p", "yAOxZQgZsv", "luzsb731h5"] - } - ], - path: ["lk860n5p", "yAOxZQgZsv"] - } - ], - path: ["lk860n5p"] - } - ], - path: [] -}; diff --git a/packages/api-page-builder/__tests__/graphql/pageTemplates.test.ts b/packages/api-page-builder/__tests__/graphql/pageTemplates.test.ts deleted file mode 100644 index 1140aa5180d..00000000000 --- a/packages/api-page-builder/__tests__/graphql/pageTemplates.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import useGqlHandler from "./useGqlHandler"; -import { simplePageTemplateContent } from "~tests/graphql/mocks/pageTemplates/simplePageTemplateContent"; -import { simplePageBlockContent } from "~tests/graphql/mocks/pageTemplates/simplePageBlockContent"; - -jest.setTimeout(100000); - -describe("Page Templates Test", () => { - const { - createPageTemplate, - updatePageTemplate, - updatePage, - unlinkPageFromTemplate, - createPageFromTemplate, - createCategory, - createBlockCategory, - createPageBlock - } = useGqlHandler(); - - test("unlinking a page from a page template should remove all template-related data", async () => { - await createCategory({ - data: { - slug: `slug`, - name: `name`, - url: `/some-url/`, - layout: `layout` - } - }); - - const pageTemplate = await createPageTemplate({ - data: { - title: "test-template", - slug: "test-template", - description: "test", - tags: [], - layout: "static", - pageCategory: "slug" - } - }).then(([response]) => response.data.pageBuilder.createPageTemplate.data); - - await updatePageTemplate({ - id: pageTemplate.id, - data: { - content: simplePageTemplateContent - } - }); - - const pageCreatedFromTemplate = await createPageFromTemplate({ - category: "slug", - templateId: pageTemplate.id, - meta: { - location: { - folderId: "root" - } - } - }).then(([response]) => response.data.pageBuilder.createPageFromTemplate.data); - - // Update values of "Heading text" and "Paragraph text" variables. This how it's done in the page editor. - await updatePage({ - id: pageCreatedFromTemplate.id, - data: { - content: { - id: "lk81y1na", - type: "document", - data: { - template: { - variables: [ - { - blockId: "yAOxZQgZsv", - variables: [ - { - id: "aAUBVaa1fB", - type: "heading", - label: "Heading text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-HEADING","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "iwpP2qZAHy", - type: "paragraph", - label: "Paragraph text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-PARAGRAPH","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - ] - } - ] - } - }, - elements: [], - path: [] - } - } - }); - - // Unlinked page should no longer contain template variable-related data. - const unlinkedPage = await unlinkPageFromTemplate({ id: pageCreatedFromTemplate.id }).then( - ([response]) => response.data.pageBuilder.unlinkPageFromTemplate.data - ); - - expect(unlinkedPage.content).toMatchObject({ - id: "lk81y1na", - type: "document", - data: {}, - elements: [ - { - id: "yAOxZQgZsv", - type: "block", - data: { - variables: [] - }, - elements: [ - { - elements: [ - { - elements: [ - { - type: "heading", - data: { - text: { - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-HEADING","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - }, - variableId: "aAUBVaa1fB" - } - }, - { - type: "paragraph", - data: { - text: { - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-PARAGRAPH","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' - } - } - } - } - ] - } - ] - } - ] - } - ] - }); - }); - - test("unlinking a page from a page template that contains a block should remove all template-related data", async () => { - await createCategory({ - data: { - slug: `slug`, - name: `name`, - url: `/some-url/`, - layout: `layout` - } - }); - - await createBlockCategory({ - data: { - slug: `block-category`, - name: `block-category-name`, - icon: `block-category-icon`, - description: `block-category-description` - } - }); - - const pageBlock = await createPageBlock({ - data: { - name: "New block", - blockCategory: "block-category", - content: simplePageBlockContent - } - }).then(([response]) => response.data.pageBuilder.createPageBlock.data); - - const pageTemplate = await createPageTemplate({ - data: { - title: "test-template", - slug: "test-template", - description: "test", - tags: [], - layout: "static", - pageCategory: "slug" - } - }).then(([response]) => response.data.pageBuilder.createPageTemplate.data); - - // Add block to the page template. - await updatePageTemplate({ - id: pageTemplate.id, - data: { - content: { - id: "lkb798zg", - type: "document", - data: { - template: { - variables: [ - { - blockId: "DABBrS43HC", - variables: [] - } - ] - } - }, - elements: [ - { - id: "DABBrS43HC", - type: "block", - data: { - templateBlockId: "DABBrS43HC", - settings: { - width: { - desktop: { - value: "100%" - } - }, - margin: { - desktop: { - top: "0px", - right: "0px", - bottom: "0px", - left: "0px", - advanced: true - } - }, - padding: { - desktop: { - all: "10px" - } - }, - horizontalAlignFlex: { - desktop: "center" - }, - verticalAlign: { - desktop: "flex-start" - } - }, - variables: [], - blockId: pageBlock.id - }, - elements: [], - path: ["lkb798zg"] - } - ], - path: [] - } - } - }); - - const pageCreatedFromTemplate = await createPageFromTemplate({ - category: "slug", - templateId: pageTemplate.id, - meta: { - location: { - folderId: "root" - } - } - }).then(([response]) => response.data.pageBuilder.createPageFromTemplate.data); - - // Update values of "Heading text" and "Paragraph text" variables. This how it's done in the page editor. - await updatePage({ - id: pageCreatedFromTemplate.id, - data: { - content: { - id: "lkb798zg", - type: "document", - data: { - template: { - variables: [ - { - blockId: "DABBrS43HC", - variables: [ - { - id: "DABBrS43HC#FDEezrJ8NH", - label: "Heading text", - type: "heading", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-HEADING","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "DABBrS43HC#SezNLOdXw3", - label: "Paragraph text", - type: "paragraph", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-PARAGRAPH","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - } - ] - } - ], - slug: "test-template" - } - }, - elements: [], - path: [] - } - } - }); - - // Unlinked page should no longer contain template variable-related data. - const unlinkedPage = await unlinkPageFromTemplate({ id: pageCreatedFromTemplate.id }).then( - ([response]) => response.data.pageBuilder.unlinkPageFromTemplate.data - ); - - expect(unlinkedPage.content).toMatchObject({ - id: "lkb798zg", - type: "document", - data: {}, - elements: [ - { - id: "DABBrS43HC", - type: "block", - data: { - variables: [ - { - id: "DABBrS43HC#FDEezrJ8NH", - type: "heading", - label: "Heading text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-HEADING","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - }, - { - id: "DABBrS43HC#SezNLOdXw3", - type: "paragraph", - label: "Paragraph text", - value: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-PARAGRAPH","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - } - ], - blockId: pageBlock.id - }, - elements: [ - { - type: "grid", - elements: [ - { - type: "cell", - elements: [ - { - type: "heading", - data: { - text: { - desktop: { - type: "heading", - alignment: "left", - tag: "h1" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-HEADING","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading-element","version":1,"tag":"h1","styles":[{"styleId":"heading1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - } - }, - variableId: "DABBrS43HC#FDEezrJ8NH" - } - }, - { - type: "paragraph", - data: { - text: { - desktop: { - type: "paragraph", - alignment: "left", - tag: "p" - }, - data: { - text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"UPDATED-PARAGRAPH","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph-element","version":1,"styles":[{"styleId":"paragraph1","type":"typography"}]}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' - } - }, - variableId: "DABBrS43HC#SezNLOdXw3" - } - } - ] - } - ] - } - ] - } - ] - }); - }); -}); diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/GetOrCreateTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/GetOrCreateTranslatableCollectionUseCase.test.ts new file mode 100644 index 00000000000..5fe20d1d373 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatableCollection/GetOrCreateTranslatableCollectionUseCase.test.ts @@ -0,0 +1,16 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { GetOrCreateTranslatableCollectionUseCase } from "~/translations"; + +describe("GetOrCreateTranslatableCollectionUseCase", () => { + it("should return a new TranslatableCollection object, if a collection doesn't exist", async () => { + const { handler } = useHandler(); + const context = await handler(); + + const getTranslatableCollection = new GetOrCreateTranslatableCollectionUseCase(context); + const collection = await getTranslatableCollection.execute("collection:1"); + + expect(collection.getCollectionId()).toEqual("collection:1"); + expect(collection.getItems()).toEqual([]); + expect(collection.getId()).toBeUndefined(); + }); +}); diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/GetTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/GetTranslatableCollectionUseCase.test.ts new file mode 100644 index 00000000000..333d2458e89 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatableCollection/GetTranslatableCollectionUseCase.test.ts @@ -0,0 +1,14 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { GetTranslatableCollectionUseCase } from "~/translations"; + +describe("GetTranslatableCollectionUseCase", () => { + it(`should return "undefined" if a collection doesn't exist`, async () => { + const { handler } = useHandler(); + const context = await handler(); + + const getTranslatableCollection = new GetTranslatableCollectionUseCase(context); + const loader = () => getTranslatableCollection.execute("collection:1"); + + await expect(loader()).resolves.toBe(undefined); + }); +}); diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/SaveTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/SaveTranslatableCollectionUseCase.test.ts new file mode 100644 index 00000000000..be41fe43763 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatableCollection/SaveTranslatableCollectionUseCase.test.ts @@ -0,0 +1,84 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { SaveTranslatableCollectionUseCase } from "~/translations"; +import { defaultIdentity } from "~tests/tenancySecurity"; +import { Identity } from "~/translations/Identity"; + +const anotherIdentity: Identity = { + id: "87654321", + type: "admin", + displayName: "Jane Doe" +}; + +describe("SaveTranslatableCollectionUseCase", () => { + it("should save a collection (create a new one, or update an existing one)", async () => { + const { handler } = useHandler(); + const context = await handler(); + + const saveTranslatableCollection = new SaveTranslatableCollectionUseCase(context); + const newCollection = await saveTranslatableCollection.execute({ + collectionId: "collection:1", + items: [ + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" } + ] + }); + + expect(newCollection.getCollectionId()).toEqual("collection:1"); + + const newItemsDto = newCollection.getItems().map(item => { + return { + itemId: item.itemId, + value: item.value, + modifiedOn: item.modifiedOn, + modifiedBy: item.modifiedBy + }; + }); + + expect(newItemsDto).toEqual([ + { + itemId: "element:1", + value: "Value 1", + modifiedOn: expect.any(Date), + modifiedBy: defaultIdentity + }, + { + itemId: "element:2", + value: "Value 2", + modifiedOn: expect.any(Date), + modifiedBy: defaultIdentity + } + ]); + + // Pivot + context.security.setIdentity(anotherIdentity); + + const updatedCollection = await saveTranslatableCollection.execute({ + collectionId: "collection:1", + items: [ + // If we pass the same `itemId` and `value`, the `modifiedOn` timestamp must not change! + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" }, + { itemId: "element:3", value: "Value 3" } + ] + }); + + const updatedItemsDto = updatedCollection.getItems().map(item => { + return { + itemId: item.itemId, + value: item.value, + modifiedOn: item.modifiedOn, + modifiedBy: item.modifiedBy + }; + }); + + expect(updatedItemsDto).toEqual([ + ...newItemsDto, + { + itemId: "element:3", + value: "Value 3", + modifiedOn: expect.any(Date), + modifiedBy: anotherIdentity + } + ]); + }); +}); diff --git a/packages/api-page-builder/__tests__/translations/translatedCollection/SaveTranslatedCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatedCollection/SaveTranslatedCollectionUseCase.test.ts new file mode 100644 index 00000000000..af595978440 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatedCollection/SaveTranslatedCollectionUseCase.test.ts @@ -0,0 +1,84 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { + SaveTranslatableCollectionUseCase, + SaveTranslatableCollectionParams, + SaveTranslatedCollectionUseCase +} from "~/translations"; +import { PbContext } from "~/graphql/types"; +import { TranslatedItem } from "~/translations/translatedCollection/domain/TranslatedItem"; + +const createTranslatableCollection = async ( + context: PbContext, + params: SaveTranslatableCollectionParams +) => { + const saveCollection = new SaveTranslatableCollectionUseCase(context); + await saveCollection.execute(params); +}; + +const translatedItemsToDto = (items: TranslatedItem[]) => { + return items.map(item => { + return { + itemId: item.itemId, + value: item.value, + translatedOn: item.translatedOn + }; + }); +}; + +describe("SaveTranslatedCollectionUseCase", () => { + it("should save translations", async () => { + const { handler } = useHandler(); + const context = await handler(); + + // Setup + await createTranslatableCollection(context, { + collectionId: "collection:1", + items: [ + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" }, + { itemId: "element:3", value: "Value 3" } + ] + }); + + // Test 1 + const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context); + const newCollection = await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en", + items: [ + { itemId: "element:1", value: "Translated Value 1" }, + { itemId: "element:2", value: "Translated Value 2" } + ] + }); + + const translatedItemsDto = translatedItemsToDto(newCollection.getItems()); + + expect(translatedItemsDto).toEqual([ + { itemId: "element:1", value: "Translated Value 1", translatedOn: expect.any(Date) }, + { itemId: "element:2", value: "Translated Value 2", translatedOn: expect.any(Date) }, + { itemId: "element:3", value: undefined, translatedOn: undefined } + ]); + + // Test 2 + const updatedCollection = await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en", + items: [{ itemId: "element:3", value: "Translated Value 3" }] + }); + + const updatedItemsDto = translatedItemsToDto(updatedCollection.getItems()); + expect(updatedItemsDto).toEqual([ + { + itemId: "element:1", + value: "Translated Value 1", + translatedOn: translatedItemsDto[0].translatedOn + }, + { + itemId: "element:2", + value: "Translated Value 2", + translatedOn: translatedItemsDto[1].translatedOn + }, + { itemId: "element:3", value: "Translated Value 3", translatedOn: expect.any(Date) } + ]); + }); +}); diff --git a/packages/api-page-builder/__tests__/translations/useHandler.ts b/packages/api-page-builder/__tests__/translations/useHandler.ts new file mode 100644 index 00000000000..9797cb2379f --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/useHandler.ts @@ -0,0 +1,57 @@ +import { createEventHandler, createHandler } from "@webiny/handler-aws/raw"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createFileManagerContext } from "@webiny/api-file-manager"; +import { PbContext } from "~/graphql/types"; +import { PageBuilderStorageOperations } from "~/types"; +import { createTenancyAndSecurity } from "~tests/tenancySecurity"; +import { createPageBuilderContext } from "~/graphql"; +import { LambdaContext } from "@webiny/handler-aws/types"; + +export const useHandler = () => { + const i18nStorage = getStorageOps("i18n"); + const pageBuilderStorage = getStorageOps("pageBuilder"); + const fileManagerStorage = getStorageOps("fileManager"); + const cmsStorage = getStorageOps("cms"); + + const handler = createHandler({ + plugins: [ + createEventHandler(async ({ context }) => { + return context; + }), + ...cmsStorage.plugins, + ...pageBuilderStorage.plugins, + ...createTenancyAndSecurity(), + i18nContext(), + i18nStorage.storageOperations, + mockLocalesPlugins(), + createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), + createFileManagerContext({ storageOperations: fileManagerStorage.storageOperations }), + createPageBuilderContext({ storageOperations: pageBuilderStorage.storageOperations }), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }) + ] + }); + + return { + handler: () => { + return handler( + { + headers: { + "x-tenant": "root" + } + }, + {} as LambdaContext + ); + } + }; +}; diff --git a/packages/api-page-builder/src/graphql/crud/pages.crud.ts b/packages/api-page-builder/src/graphql/crud/pages.crud.ts index 455895bf06f..7f8fb4a8e84 100644 --- a/packages/api-page-builder/src/graphql/crud/pages.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pages.crud.ts @@ -8,7 +8,6 @@ import { Page, PageBuilderContextObject, PageBuilderStorageOperations, - PageContentWithTemplate, PageElementProcessor, PagesCrud, PageStorageOperationsGetWhereParams, @@ -506,63 +505,6 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => { } }, - async unlinkPageFromTemplate(this: PageBuilderContextObject, id): Promise { - const page = await this.getPage(id); - if (!page) { - throw new NotFoundError(`Page not found.`); - } - - if (!page.content?.data.template) { - throw new WebinyError( - "Cannot continue because the page is not linked to a template." - ); - } - - const resolvedPageElements = await context.pageBuilder.resolvePageTemplate( - page.content as PageContentWithTemplate - ); - - // Run element processors on the full page content for potential transformations. - const processedPage = await context.pageBuilder.processPageContent({ - ...page, - content: { ...page.content, elements: resolvedPageElements } - }); - - // Delete template-related data. - const allTemplateVariableIds = processedPage - .content!.data.template.variables.map((variablesForBlock: Record) => { - return variablesForBlock.variables.map((v: Record) => v.id); - }) - .flat(); - - for (let i = 0; i < processedPage.content!.elements.length; i++) { - const blockElement = processedPage.content!.elements[i]; - - if ("templateBlockId" in blockElement.data) { - delete blockElement.data.templateBlockId; - - // In the presence of a block ID, we know that this block is not a template block. - // Variable values need to stay intact. - if (blockElement.data.blockId) { - continue; - } - - // Let's delete all template-related variables on block. - if (Array.isArray(blockElement.data.variables)) { - blockElement.data.variables = blockElement.data.variables.filter( - (variable: Record) => - !allTemplateVariableIds.includes(variable.id) - ); - } - } - } - - // Delete base template-related data. - delete processedPage.content!.data.template; - - return this.updatePage(id, processedPage); - }, - async updatePage(id, input): Promise { await pagesPermissions.ensure({ rwd: "w" }); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/button.ts b/packages/api-page-builder/src/graphql/elementProcessors/button.ts deleted file mode 100644 index 3bd81cafd9b..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/button.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "button") { - return; - } - - const variables = useElementVariables(block, element); - - const label = variables?.find(variable => variable.id.endsWith(".label"))?.value || null; - const url = variables?.find(variable => variable.id.endsWith(".url"))?.value || null; - - if (label) { - set(element, "data.buttonText", label); - } - - if (url) { - set(element, "data.action.href", url); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/embed.ts b/packages/api-page-builder/src/graphql/elementProcessors/embed.ts deleted file mode 100644 index 0ec3216f46c..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/embed.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { fetchEmbed, findProvider } from "~/graphql/graphql/pages/oEmbed"; -import { useElementVariables } from "./useElementVariables"; - -const supportedTypes = ["soundcloud", "vimeo", "youtube", "pinterest", "twitter"]; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(async ({ block, element }) => { - if (!supportedTypes.includes(element.type)) { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== undefined && value !== null) { - set(element, "data.source.url", value); - - const provider = findProvider(value); - if (provider) { - try { - const oembed = await fetchEmbed({ url: value }, provider); - set(element, "data.oembed", oembed); - } catch {} - } - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/icon.ts b/packages/api-page-builder/src/graphql/elementProcessors/icon.ts deleted file mode 100644 index b2b2c21b8ce..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/icon.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "icon") { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== null) { - set(element, "data.icon.id", value.id); - set(element, "data.icon.svg", value.svg); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/iframe.ts b/packages/api-page-builder/src/graphql/elementProcessors/iframe.ts deleted file mode 100644 index 4c8e8f80ec6..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/iframe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "iframe") { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== null) { - set(element, "data.iframe.url", value); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/image.ts b/packages/api-page-builder/src/graphql/elementProcessors/image.ts deleted file mode 100644 index 56d97e34e10..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/image.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "image") { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== null) { - set(element, "data.image.file", value); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/images.ts b/packages/api-page-builder/src/graphql/elementProcessors/images.ts deleted file mode 100644 index 53a453a2f54..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/images.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "images-list") { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== null) { - set(element, "data.images", value); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/index.ts b/packages/api-page-builder/src/graphql/elementProcessors/index.ts deleted file mode 100644 index 53eb5b5b67a..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import paragraphElementProcessor from "./paragraph"; -import buttonElementProcessor from "./button"; -import imageElementProcessor from "./image"; -import imagesElementProcessor from "./images"; -import iconElementProcessor from "./icon"; -import embedElementProcessor from "./embed"; -import iframeElementProcessor from "./iframe"; - -export const createElementProcessors = () => { - return [ - paragraphElementProcessor, - buttonElementProcessor, - imageElementProcessor, - imagesElementProcessor, - iconElementProcessor, - embedElementProcessor, - iframeElementProcessor - ]; -}; diff --git a/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts b/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts deleted file mode 100644 index f38fb54b035..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -const supportedTypes = ["paragraph", "heading", "quote", "list"]; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (!supportedTypes.includes(element.type)) { - return; - } - - const variables = useElementVariables(block, element); - const value = variables?.length > 0 ? variables[0].value : null; - - if (value !== null) { - set(element, "data.text.data.text", value); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/quote.ts b/packages/api-page-builder/src/graphql/elementProcessors/quote.ts deleted file mode 100644 index 31b4e7fce15..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/quote.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ContextPlugin } from "@webiny/handler"; -import set from "lodash/set"; -import { PbContext } from "~/graphql/types"; -import { useElementVariables } from "./useElementVariables"; - -export default new ContextPlugin(context => { - context.pageBuilder.addPageElementProcessor(({ block, element }) => { - if (element.type !== "button") { - return; - } - - const variables = useElementVariables(block, element); - - const label = variables.find(variable => variable.id.endsWith(".label"))?.value || null; - const url = variables.find(variable => variable.id.endsWith(".url"))?.value || null; - - if (label) { - set(element, "data.buttonText", label); - } - - if (url) { - set(element, "data.action.href", url); - } - }); -}); diff --git a/packages/api-page-builder/src/graphql/elementProcessors/useElementVariables.ts b/packages/api-page-builder/src/graphql/elementProcessors/useElementVariables.ts deleted file mode 100644 index 0464af31dab..00000000000 --- a/packages/api-page-builder/src/graphql/elementProcessors/useElementVariables.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PbBlockVariable, PbPageElement } from "~/graphql/types"; - -export function useElementVariables( - block: PbPageElement, - element: PbPageElement -): PbBlockVariable[] { - if (element.data?.variableId) { - return block.data?.variables?.filter((variable: PbBlockVariable) => { - return variable.id.split(".")[0] === element.data?.variableId; - }); - } - - return []; -} diff --git a/packages/api-page-builder/src/graphql/graphql/base.gql.ts b/packages/api-page-builder/src/graphql/graphql/base.gql.ts index 6b8c4eefbe7..8a2ccce9295 100644 --- a/packages/api-page-builder/src/graphql/graphql/base.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/base.gql.ts @@ -17,7 +17,7 @@ export const createBaseGraphQL = (): GraphQLSchemaPlugin => { src: String! } - type PbCreatedBy { + type PbIdentity { id: ID displayName: String type: String diff --git a/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts index 086a78d50e0..f66434efb46 100644 --- a/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts @@ -17,7 +17,7 @@ export const createBlockCategoryGraphQL = (): GraphQLSchemaPlugin => typeDefs: /* GraphQL */ ` type PbBlockCategory { createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity name: String slug: String icon: String diff --git a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts index c19b6e1553f..7f17ec6db47 100644 --- a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts @@ -17,7 +17,7 @@ export const createCategoryGraphQL = (): GraphQLSchemaPlugin => { typeDefs: /* GraphQL */ ` type PbCategory { createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity name: String slug: String url: String diff --git a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts index 0e194d39165..d37aceaaa01 100644 --- a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts @@ -10,7 +10,7 @@ export const createMenuGraphQL = (): GraphQLSchemaPlugin => { type PbMenu { id: ID createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity title: String slug: String description: String diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts index 2217e438f12..073e3641db5 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -10,7 +10,7 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ type PbPageBlock { id: ID createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity name: String blockCategory: String content: JSON diff --git a/packages/api-page-builder/src/graphql/graphql/pageElements.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageElements.gql.ts index b7d123d3745..440aaf09874 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageElements.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageElements.gql.ts @@ -10,7 +10,7 @@ export const createPageElementsGraphQL = (): GraphQLSchemaPlugin => { type PbPageElement { id: ID createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity name: String type: String content: JSON diff --git a/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts index a04b9c4e1fa..49cd63974ce 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts @@ -22,7 +22,7 @@ export const createPageTemplateGraphQL = new GraphQLSchemaPlugin({ content: JSON! createdOn: DateTime! savedOn: DateTime! - createdBy: PbCreatedBy! + createdBy: PbIdentity! layout: String pageCategory: String } diff --git a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts index 833838bd72c..89a4fb86a14 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -34,7 +34,7 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { uniquePageId: ID editor: String createdFrom: ID - createdBy: PbCreatedBy + createdBy: PbIdentity createdOn: DateTime savedOn: DateTime publishedOn: DateTime @@ -83,7 +83,7 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { savedOn: DateTime createdFrom: ID createdOn: DateTime - createdBy: PbCreatedBy + createdBy: PbIdentity settings: JSON } @@ -244,8 +244,6 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { # Duplicate page by given ID. duplicatePage(id: ID!, meta: JSON): PbPageResponse - unlinkPageFromTemplate(id: ID!): PbPageResponse - # Publish page publishPage(id: ID!): PbPageResponse @@ -499,12 +497,6 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { } }, - unlinkPageFromTemplate: async (_, args: any, context) => { - return resolve(() => { - return context.pageBuilder.unlinkPageFromTemplate(args.id); - }); - }, - publishPage: async (_, args: any, context) => { return resolve(() => context.pageBuilder.publishPage(args.id)); }, diff --git a/packages/api-page-builder/src/graphql/index.ts b/packages/api-page-builder/src/graphql/index.ts index 1d5b35862d1..b0e3692a1d8 100644 --- a/packages/api-page-builder/src/graphql/index.ts +++ b/packages/api-page-builder/src/graphql/index.ts @@ -1,17 +1,15 @@ -export { useElementVariables } from "./elementProcessors/useElementVariables"; - -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; import { createCrud, CreateCrudParams } from "./crud"; import graphql from "./graphql"; -import { createElementProcessors } from "~/graphql/elementProcessors"; +import { createTranslations, createTranslationsGraphQl } from "~/translations/createTranslations"; +import { PluginCollection } from "@webiny/plugins/types"; -export const createPageBuilderGraphQL = (): GraphQLSchemaPlugin[] => { - return graphql(); +export const createPageBuilderGraphQL = (): PluginCollection => { + return [...graphql(), ...createTranslationsGraphQl()]; }; export type ContextParams = CreateCrudParams; export const createPageBuilderContext = (params: ContextParams) => { - return [createCrud(params), createElementProcessors()]; + return [createCrud(params), ...createTranslations()]; }; export * from "./crud/pages/PageContent"; diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index fe1d0cf352d..99d11b1a79e 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -215,7 +215,6 @@ export interface PagesCrud { page: string, meta?: Record ): Promise; - unlinkPageFromTemplate(id: string): Promise; updatePage(id: string, data: PbUpdatePageInput): Promise; deletePage(id: string): Promise<[TPage, TPage]>; publishPage(id: string): Promise; diff --git a/packages/api-page-builder/src/translations/GetModel.ts b/packages/api-page-builder/src/translations/GetModel.ts new file mode 100644 index 00000000000..37fd01aec9d --- /dev/null +++ b/packages/api-page-builder/src/translations/GetModel.ts @@ -0,0 +1,13 @@ +import { PbContext } from "~/graphql/types"; + +export class GetModel { + static async byModelId(context: PbContext, modelId: string) { + const model = await context.cms.getModel(modelId); + + if (!model) { + throw new Error(`Model "${modelId}" was not found!`); + } + + return model; + } +} diff --git a/packages/api-page-builder/src/translations/Identifier.ts b/packages/api-page-builder/src/translations/Identifier.ts new file mode 100644 index 00000000000..fa83dc3cf17 --- /dev/null +++ b/packages/api-page-builder/src/translations/Identifier.ts @@ -0,0 +1,7 @@ +import { mdbid } from "@webiny/utils"; + +export class Identifier { + static generate() { + return mdbid(); + } +} diff --git a/packages/api-page-builder/src/translations/Identity.ts b/packages/api-page-builder/src/translations/Identity.ts new file mode 100644 index 00000000000..4f82934e943 --- /dev/null +++ b/packages/api-page-builder/src/translations/Identity.ts @@ -0,0 +1,5 @@ +export interface Identity { + id: string; + type: string; + displayName: string; +} diff --git a/packages/api-page-builder/src/translations/TranslationsQueries.graphql b/packages/api-page-builder/src/translations/TranslationsQueries.graphql new file mode 100644 index 00000000000..50a37c95de4 --- /dev/null +++ b/packages/api-page-builder/src/translations/TranslationsQueries.graphql @@ -0,0 +1,99 @@ +query GetCollectionById { + translations { + getTranslatableCollection(collectionId: "page:1") { + data { + collectionId + lastModified + items { + itemId + value + modifiedOn + modifiedBy { + id + displayName + } + } + } + } + } +} + +mutation UpdateCollection { + translations { + saveTranslatableCollection( + collectionId: "page:1" + items: [{ itemId: "element:1", value: "Hello" }, { itemId: "element:2", value: "Button" }] + ) { + data { + collectionId + lastModified + items { + itemId + value + modifiedOn + modifiedBy { + id + displayName + } + } + } + } + } +} + +query GetTranslatedCollection { + translations { + getTranslatedCollection(collectionId: "page:1", languageCode: "en") { + data { + collectionId + languageCode + items { + itemId + baseValue + value + translatedOn + translatedBy { + id + displayName + } + } + } + error { + code + message + } + } + } +} + +mutation UpdateTranslatedCollection { + translations { + saveTranslatedCollection( + collectionId: "page:1" + languageCode: "en" + items: [ + { itemId: "element:1", value: "Translated value 1" } + { itemId: "element:2", value: "Translated value 2" } + ] + ) { + data { + collectionId + languageCode + items { + itemId + baseValue + value + translatedOn + translatedBy { + id + displayName + } + } + } + error { + code + message + } + } + } +} diff --git a/packages/api-page-builder/src/translations/createTranslations.ts b/packages/api-page-builder/src/translations/createTranslations.ts new file mode 100644 index 00000000000..3392c877ac2 --- /dev/null +++ b/packages/api-page-builder/src/translations/createTranslations.ts @@ -0,0 +1,57 @@ +import lodashMerge from "lodash/merge"; +import { createGraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { PbContext } from "~/graphql/types"; +import { translatableCollectionResolvers } from "~/translations/translatableCollection/graphql/resolvers"; +import { translatableCollectionSchema } from "~/translations/translatableCollection/graphql/schema"; +import { translatedCollectionSchema } from "~/translations/translatedCollection/graphql/schema"; +import { translatedCollectionResolvers } from "~/translations/translatedCollection/graphql/resolvers"; +import { createCmsModelPlugin } from "@webiny/api-headless-cms"; +import { translatableCollectionModel } from "~/translations/translatableCollection/repository/translatableCollection.model"; +import { translatedCollectionModel } from "~/translations/translatedCollection/repository/translatedCollection.model"; + +const baseSchema = /* GraphQL */ ` + type TranslationsQuery { + _empty: String + } + + type TranslationsMutation { + _empty: String + } + + extend type Query { + translations: TranslationsQuery + } + + extend type Mutation { + translations: TranslationsMutation + } +`; + +const baseResolvers = { + Query: { + translations: () => ({}) + }, + Mutation: { + translations: () => ({}) + } +}; + +export const createTranslations = () => { + return [ + createCmsModelPlugin(translatableCollectionModel, { validateLayout: false }), + createCmsModelPlugin(translatedCollectionModel, { validateLayout: false }) + ]; +}; + +export const createTranslationsGraphQl = () => { + return [ + createGraphQLSchemaPlugin({ + typeDefs: `${baseSchema} ${translatableCollectionSchema} ${translatedCollectionSchema}`, + resolvers: lodashMerge( + baseResolvers, + translatableCollectionResolvers, + translatedCollectionResolvers + ) + }) + ]; +}; diff --git a/packages/api-page-builder/src/translations/index.ts b/packages/api-page-builder/src/translations/index.ts new file mode 100644 index 00000000000..b37419f089b --- /dev/null +++ b/packages/api-page-builder/src/translations/index.ts @@ -0,0 +1,11 @@ +// TranslatableCollection +export * from "./translatableCollection/useCases/GetTranslatableCollectionUseCase"; +export * from "./translatableCollection/useCases/SaveTranslatableCollectionUseCase"; +export * from "./translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase"; +export * from "./translatableCollection/useCases/CloneTranslatableCollectionUseCase"; + +// TranslatedCollection +export * from "./translatedCollection/useCases/GetTranslatedCollectionUseCase"; +export * from "./translatedCollection/useCases/CloneTranslatedCollectionUseCase"; +export * from "./translatedCollection/useCases/SaveTranslatedCollectionUseCase"; +export * from "./translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase"; diff --git a/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableCollection.ts b/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableCollection.ts new file mode 100644 index 00000000000..cc22dac6f56 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableCollection.ts @@ -0,0 +1,81 @@ +import { TranslatableItem } from "./TranslatableItem"; + +export interface TranslatableCollectionProps { + collectionId: string; + items?: TranslatableItem[]; +} + +export class TranslatableCollection { + private readonly props: TranslatableCollectionProps; + private id?: string; + + constructor(props: TranslatableCollectionProps, id?: string) { + this.props = props; + this.id = id; + } + + getId() { + return this.id; + } + + setId(id: string) { + if (!this.id) { + this.id = id; + } + } + + getCollectionId() { + return this.props.collectionId; + } + + getItems() { + return this.props.items || []; + } + + public setItems(items: TranslatableItem[]) { + const newItems: TranslatableItem[] = []; + + for (const item of items) { + const existingItem = this.getItems().find( + existingItem => existingItem.itemId === item.itemId + ); + + if (!existingItem) { + newItems.push(item); + continue; + } + + newItems.push( + existingItem.update({ + value: item.value, + modifiedBy: item.modifiedBy, + context: item.context ?? existingItem.context + }) + ); + } + + this.props.items = newItems; + } + + getLastModified() { + return this.getItems() + .map(item => item.modifiedOn) + .sort((a, b) => a.getTime() - b.getTime()) + .pop(); + } + + getBaseValue(itemId: string) { + const item = this.getItems().find(item => item.itemId === itemId); + return item ? item.value : undefined; + } + + getItemContext(itemId: string) { + const item = this.getItems().find(item => item.itemId === itemId); + return item ? item.context : undefined; + } + + getItemModifiedOn(itemId: string) { + const item = this.getItems().find(item => item.itemId === itemId); + return item ? item.modifiedOn : undefined; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableItem.ts b/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableItem.ts new file mode 100644 index 00000000000..6e891916d4f --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/domain/TranslatableItem.ts @@ -0,0 +1,60 @@ +import { Identity } from "~/translations/Identity"; + +interface TranslatableItemProps { + itemId: string; + value: string; + context?: Record; + modifiedOn: Date; + modifiedBy: Identity; +} + +interface UpdateProps { + value: string; + context?: Record; + modifiedBy: Identity; + modifiedOn?: Date; +} + +export class TranslatableItem { + private readonly props: TranslatableItemProps; + + private constructor(props: TranslatableItemProps) { + this.props = props; + } + + get itemId() { + return this.props.itemId; + } + + get value() { + return this.props.value; + } + + get context() { + return this.props.context; + } + + get modifiedOn() { + return this.props.modifiedOn; + } + + get modifiedBy() { + return this.props.modifiedBy; + } + + static create(props: TranslatableItemProps) { + return new TranslatableItem(props); + } + + update(props: UpdateProps): TranslatableItem { + if (props.value === this.props.value) { + return this; + } + + return new TranslatableItem({ + ...this.props, + ...props, + modifiedOn: props.modifiedOn ?? new Date() + }); + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionDTO.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionDTO.ts new file mode 100644 index 00000000000..6b6c6af9fc9 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionDTO.ts @@ -0,0 +1,8 @@ +import { GqlTranslatableItemDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableItemDTO"; + +export interface GqlTranslatableCollectionDTO { + id: string; + collectionId: string; + lastModified?: string; + items: GqlTranslatableItemDTO[]; +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper.ts new file mode 100644 index 00000000000..7bdf70b93b4 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper.ts @@ -0,0 +1,14 @@ +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { GqlTranslatableCollectionDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableCollectionDTO"; +import { GqlTranslatableItemMapper } from "~/translations/translatableCollection/graphql/GqlTranslatableItemMapper"; + +export class GqlTranslatableCollectionMapper { + static toDTO(collection: TranslatableCollection): GqlTranslatableCollectionDTO { + return { + id: collection.getId() || collection.getCollectionId(), + collectionId: collection.getCollectionId(), + lastModified: collection.getLastModified()?.toISOString(), + items: collection.getItems().map(item => GqlTranslatableItemMapper.toDTO(item)) + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemDTO.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemDTO.ts new file mode 100644 index 00000000000..6473f3e5e8b --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemDTO.ts @@ -0,0 +1,9 @@ +import { Identity } from "~/translations/Identity"; + +export interface GqlTranslatableItemDTO { + itemId: string; + value: string; + context?: Record; + modifiedOn?: string; + modifiedBy: Identity; +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemMapper.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemMapper.ts new file mode 100644 index 00000000000..fee2e855912 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/GqlTranslatableItemMapper.ts @@ -0,0 +1,14 @@ +import { TranslatableItem } from "~/translations/translatableCollection/domain/TranslatableItem"; +import { GqlTranslatableItemDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableItemDTO"; + +export class GqlTranslatableItemMapper { + static toDTO(item: TranslatableItem): GqlTranslatableItemDTO { + return { + itemId: item.itemId, + value: item.value, + context: item.context, + modifiedOn: item.modifiedOn.toISOString(), + modifiedBy: item.modifiedBy + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts new file mode 100644 index 00000000000..4d0268f1fec --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts @@ -0,0 +1,44 @@ +import { ErrorResponse, Response } from "@webiny/handler-graphql"; +import type { Resolvers } from "@webiny/handler-graphql/types"; +import type { PbContext } from "~/graphql/types"; +import { SaveTranslatableCollectionUseCase } from "~/translations/translatableCollection/useCases/SaveTranslatableCollectionUseCase"; +import type { GqlTranslatableItemDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableItemDTO"; +import { GetTranslatableCollectionByIdRepository } from "~/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository"; +import { GqlTranslatableCollectionMapper } from "~/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper"; + +interface UpdateTranslatableCollectionParams { + collectionId: string; + items: GqlTranslatableItemDTO[]; +} + +export const translatableCollectionResolvers: Resolvers = { + TranslationsQuery: { + getTranslatableCollection: async (_, args, context) => { + try { + const getById = new GetTranslatableCollectionByIdRepository(context); + const collection = await getById.execute(args.collectionId); + + return new Response(GqlTranslatableCollectionMapper.toDTO(collection)); + } catch (err) { + return new ErrorResponse(err); + } + } + }, + TranslationsMutation: { + saveTranslatableCollection: async (_, args, context) => { + const { collectionId, items } = args as UpdateTranslatableCollectionParams; + + try { + const useCase = new SaveTranslatableCollectionUseCase(context); + const collection = await useCase.execute({ + collectionId, + items + }); + + return new Response(GqlTranslatableCollectionMapper.toDTO(collection)); + } catch (err) { + return new ErrorResponse(err); + } + } + } +}; diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts new file mode 100644 index 00000000000..debbe433dd4 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts @@ -0,0 +1,43 @@ +export const translatableCollectionSchema = /* GraphQL*/ ` + type TranslatableItem { + itemId: String! + value: String! + context: JSON! + modifiedOn: DateTime! + modifiedBy: PbIdentity! + } + + type TranslatableCollection { + collectionId: ID! + lastModified: DateTime + items: [TranslatableItem!]! + } + + input TranslatableItemInput { + itemId: String! + value: String! + context: JSON + } + + type TranslatableCollectionResponse { + data: TranslatableCollection + error: PbError + } + + type SaveTranslatableCollectionResponse { + data: TranslatableCollection + error: PbError + } + + extend type TranslationsQuery { + """Get the source collection with all the items that need to be translated.""" + getTranslatableCollection(collectionId: ID!): TranslatableCollectionResponse + } + + extend type TranslationsMutation { + saveTranslatableCollection( + collectionId: ID! + items: [TranslatableItemInput!]! + ): SaveTranslatableCollectionResponse + } +`; diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/CreateTranslatableCollectionRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/CreateTranslatableCollectionRepository.ts new file mode 100644 index 00000000000..33e6d95e715 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/CreateTranslatableCollectionRepository.ts @@ -0,0 +1,22 @@ +import { PbContext } from "~/types"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatableCollectionMapper } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper"; +import { TranslatableCollectionDTO } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO"; + +export class CreateTranslatableCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collection: TranslatableCollection): Promise { + const model = await GetModel.byModelId(this.context, "translatableCollection"); + + await this.context.cms.createEntry( + model, + TranslatableCollectionMapper.toDTO(collection) + ); + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts new file mode 100644 index 00000000000..b743baf3787 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts @@ -0,0 +1,31 @@ +import { WebinyError } from "@webiny/error"; +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { TranslatableCollectionMapper } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper"; +import { TranslatableCollectionDTO } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO"; + +export class GetTranslatableCollectionByIdRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collectionId: string): Promise { + const model = await GetModel.byModelId(this.context, "translatableCollection"); + + const existingEntry = await this.context.cms.getEntry(model, { + where: { collectionId, latest: true } + }); + + if (!existingEntry) { + throw new WebinyError({ + message: `TranslatableCollection "${collectionId}" not found!`, + code: "NOT_FOUND" + }); + } + + return TranslatableCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId); + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/UpdateTranslatableCollectionRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/UpdateTranslatableCollectionRepository.ts new file mode 100644 index 00000000000..c54bb69aed9 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/UpdateTranslatableCollectionRepository.ts @@ -0,0 +1,33 @@ +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { TranslatableCollectionMapper } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper"; +import { WebinyError } from "@webiny/error"; +import { createIdentifier } from "@webiny/utils"; + +export class UpdateTranslatableCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collection: TranslatableCollection): Promise { + const model = await GetModel.byModelId(this.context, "translatableCollection"); + const dto = TranslatableCollectionMapper.toDTO(collection); + + if (!dto.id) { + throw new WebinyError({ + message: "Updating a record without an ID is not allowed!", + code: "UPDATE_WITHOUT_ID_NOT_ALLOWED" + }); + } + + const cmsId = createIdentifier({ + id: dto.id, + version: 1 + }); + + await this.context.cms.updateEntry(model, cmsId, dto); + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO.ts new file mode 100644 index 00000000000..552c9fd1a8b --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO.ts @@ -0,0 +1,6 @@ +import { TranslatableItemDTO } from "./TranslatableItemDTO"; + +export interface TranslatableCollectionDTO { + collectionId: string; + items: TranslatableItemDTO[]; +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper.ts new file mode 100644 index 00000000000..831d84c85e4 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableCollectionMapper.ts @@ -0,0 +1,23 @@ +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { TranslatableItemMapper } from "~/translations/translatableCollection/repository/mappers/TranslatableItemMapper"; +import { TranslatableCollectionDTO } from "~/translations/translatableCollection/repository/mappers/TranslatableCollectionDTO"; + +export class TranslatableCollectionMapper { + static fromDTO(dto: TranslatableCollectionDTO, id?: string): TranslatableCollection { + return new TranslatableCollection( + { + collectionId: dto.collectionId, + items: dto.items.map(item => TranslatableItemMapper.fromDTO(item)) + }, + id + ); + } + + static toDTO(collection: TranslatableCollection) { + return { + id: collection.getId(), + collectionId: collection.getCollectionId(), + items: collection.getItems().map(item => TranslatableItemMapper.toDTO(item)) + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemDTO.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemDTO.ts new file mode 100644 index 00000000000..fad1698f202 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemDTO.ts @@ -0,0 +1,9 @@ +import { Identity } from "~/translations/Identity"; + +export interface TranslatableItemDTO { + itemId: string; + value: string; + context: Record | undefined; + modifiedOn: string; + modifiedBy: Identity; +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemMapper.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemMapper.ts new file mode 100644 index 00000000000..98e35de5e1f --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/mappers/TranslatableItemMapper.ts @@ -0,0 +1,24 @@ +import { TranslatableItem } from "~/translations/translatableCollection/domain/TranslatableItem"; +import { TranslatableItemDTO } from "~/translations/translatableCollection/repository/mappers/TranslatableItemDTO"; + +export class TranslatableItemMapper { + static fromDTO(dto: TranslatableItemDTO) { + return TranslatableItem.create({ + itemId: dto.itemId, + value: dto.value, + context: dto.context, + modifiedOn: dto.modifiedOn ? new Date(dto.modifiedOn) : new Date(), + modifiedBy: dto.modifiedBy + }); + } + + static toDTO(item: TranslatableItem): TranslatableItemDTO { + return { + itemId: item.itemId, + value: item.value, + context: item.context, + modifiedOn: item.modifiedOn.toISOString(), + modifiedBy: item.modifiedBy + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/translatableCollection.model.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/translatableCollection.model.ts new file mode 100644 index 00000000000..e3813ab81ee --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/translatableCollection.model.ts @@ -0,0 +1,88 @@ +import { createPrivateModel } from "@webiny/api-headless-cms"; + +export const translatableCollectionModel = createPrivateModel({ + name: "Translatable Collection", + modelId: "translatableCollection", + titleFieldId: "collectionId", + fields: [ + { + id: "collectionId", + fieldId: "collectionId", + storageId: "text@collectionId", + type: "text", + label: "Collection ID", + tags: [], + multipleValues: false, + validation: [ + { + name: "required", + settings: {}, + message: "Value is required." + } + ] + }, + { + id: "items", + fieldId: "items", + storageId: "object@items", + type: "object", + label: "Items", + tags: [], + multipleValues: true, + validation: [], + listValidation: [], + settings: { + fields: [ + { + id: "itemId", + label: "Item ID", + type: "text", + validation: [ + { + message: "Value is required.", + name: "required" + } + ], + fieldId: "itemId", + storageId: "text@itemId" + }, + { + id: "value", + label: "Value", + type: "long-text", + validation: [], + fieldId: "value", + storageId: "long-text@value" + }, + { + id: "context", + label: "Context", + type: "json", + validation: [], + fieldId: "context", + storageId: "json@context" + }, + { + id: "modifiedBy", + label: "Modified by", + type: "json", + validation: [], + fieldId: "modifiedBy", + storageId: "json@modifiedBy" + }, + { + settings: { + type: "dateTimeWithoutTimezone" + }, + id: "modifiedOn", + label: "Modified On", + type: "datetime", + validation: [], + fieldId: "modifiedOn", + storageId: "datetime@modifiedOn" + } + ] + } + } + ] +}); diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/CloneTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/CloneTranslatableCollectionUseCase.ts new file mode 100644 index 00000000000..288206bd382 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/CloneTranslatableCollectionUseCase.ts @@ -0,0 +1,49 @@ +import { WebinyError } from "@webiny/error"; +import { PbContext } from "~/graphql/types"; +import { + GetTranslatableCollectionUseCase, + SaveTranslatableCollectionUseCase +} from "~/translations"; +import { TranslatableCollection } from "../domain/TranslatableCollection"; + +interface CloneTranslatableCollectionParams { + sourceCollectionId: string; + newCollectionId: string; +} + +export class CloneTranslatableCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute({ + sourceCollectionId, + newCollectionId + }: CloneTranslatableCollectionParams): Promise { + // Clone the translatable collection. + const getCollection = new GetTranslatableCollectionUseCase(this.context); + const baseCollection = await getCollection.execute(sourceCollectionId); + + if (!baseCollection) { + throw new WebinyError({ + code: "SOURCE_COLLECTION_NOT_FOUND", + message: `TranslatableCollection ${sourceCollectionId} was not found!` + }); + } + + const saveCollection = new SaveTranslatableCollectionUseCase(this.context); + + return await saveCollection.execute({ + collectionId: newCollectionId, + items: baseCollection.getItems().map(item => ({ + itemId: item.itemId, + value: item.value, + // We want to preserve the modification date, so we forward the current value! + modifiedOn: item.modifiedOn.toISOString(), + context: item.context + })) + }); + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase.ts new file mode 100644 index 00000000000..46b9d976993 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase.ts @@ -0,0 +1,22 @@ +import { PbContext } from "~/types"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { GetTranslatableCollectionUseCase } from "~/translations/translatableCollection/useCases/GetTranslatableCollectionUseCase"; + +export class GetOrCreateTranslatableCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collectionId: string): Promise { + const getById = new GetTranslatableCollectionUseCase(this.context); + const collection = await getById.execute(collectionId); + + if (!collection) { + return new TranslatableCollection({ collectionId }); + } + + return collection; + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/GetTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/GetTranslatableCollectionUseCase.ts new file mode 100644 index 00000000000..92fe37cf46b --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/GetTranslatableCollectionUseCase.ts @@ -0,0 +1,20 @@ +import { PbContext } from "~/types"; +import type { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { GetTranslatableCollectionByIdRepository } from "~/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository"; + +export class GetTranslatableCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collectionId: string): Promise { + try { + const getById = new GetTranslatableCollectionByIdRepository(this.context); + return await getById.execute(collectionId); + } catch { + return undefined; + } + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/SaveTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/SaveTranslatableCollectionUseCase.ts new file mode 100644 index 00000000000..7adf0719887 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/SaveTranslatableCollectionUseCase.ts @@ -0,0 +1,65 @@ +import { PbContext } from "~/graphql/types"; +import { UpdateTranslatableCollectionRepository } from "~/translations/translatableCollection/repository/UpdateTranslatableCollectionRepository"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; +import { GetOrCreateTranslatableCollectionUseCase } from "~/translations/translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase"; +import { CreateTranslatableCollectionRepository } from "~/translations/translatableCollection/repository/CreateTranslatableCollectionRepository"; +import { Identifier } from "~/translations/Identifier"; +import { TranslatableItem } from "~/translations/translatableCollection/domain/TranslatableItem"; + +export interface SaveTranslatableCollectionParams { + collectionId: string; + items: Array<{ + itemId: string; + value: string; + modifiedOn?: string; + context?: Record; + }>; +} + +export class SaveTranslatableCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: SaveTranslatableCollectionParams): Promise { + const getOrCreate = new GetOrCreateTranslatableCollectionUseCase(this.context); + const collection = await getOrCreate.execute(params.collectionId); + + const identity = this.getIdentity(); + + const items = params.items.map(item => { + return TranslatableItem.create({ + itemId: item.itemId, + value: item.value, + context: item.context, + modifiedOn: item.modifiedOn ? new Date(item.modifiedOn) : new Date(), + modifiedBy: identity + }); + }); + + collection.setItems(items); + + if (collection.getId()) { + const update = new UpdateTranslatableCollectionRepository(this.context); + await update.execute(collection); + } else { + collection.setId(Identifier.generate()); + const create = new CreateTranslatableCollectionRepository(this.context); + await create.execute(collection); + } + + return collection; + } + + private getIdentity() { + const identity = this.context.security.getIdentity(); + + return { + id: identity.id, + type: identity.type, + displayName: identity.displayName || "" + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedCollection.ts b/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedCollection.ts new file mode 100644 index 00000000000..03c6fa8b18f --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedCollection.ts @@ -0,0 +1,67 @@ +import { TranslatedItem } from "./TranslatedItem"; + +interface TranslatedCollectionProps { + collectionId: string; + languageCode: string; + items: TranslatedItem[]; +} + +export class TranslatedCollection { + private props: TranslatedCollectionProps; + private id?: string; + + constructor(props: TranslatedCollectionProps, id?: string) { + this.props = props; + this.id = id; + } + + getId() { + return this.id; + } + + setId(id: string) { + if (!this.id) { + this.id = id; + } + } + + getCollectionId() { + return this.props.collectionId; + } + + getLanguageCode() { + return this.props.languageCode; + } + + getItems() { + return this.props.items; + } + + setItems(items: TranslatedItem[]) { + this.props.items = items; + } + + updateItems(newItems: TranslatedItem[]) { + const items = this.getItems().map(item => { + const newItem = newItems.find(newItem => newItem.itemId === item.itemId); + + // You can't set an item that doesn't exist in the original collection. + if (!newItem) { + return item; + } + + if (newItem.value !== item.value) { + return newItem; + } + + return item; + }); + + this.props.items = items; + } + + getTranslatedValue(itemId: string) { + const item = this.getItems().find(item => item.itemId === itemId); + return item ? item.value : undefined; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedItem.ts b/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedItem.ts new file mode 100644 index 00000000000..f15525c302d --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/domain/TranslatedItem.ts @@ -0,0 +1,40 @@ +import { Identity } from "~/translations/Identity"; + +interface TranslatedItemProps { + itemId: string; + value?: string; + translatedOn?: Date; + translatedBy?: Identity; +} + +export class TranslatedItem { + private readonly props: TranslatedItemProps; + + private constructor(props: TranslatedItemProps) { + this.props = props; + } + + get itemId() { + return this.props.itemId; + } + + get value() { + return this.props.value; + } + + get translatedOn() { + return this.props.translatedOn; + } + + get translatedBy() { + return this.props.translatedBy; + } + + static create(props: TranslatedItemProps) { + return new TranslatedItem(props); + } + + translatedAfter(date: Date) { + return this.translatedOn && this.translatedOn.getTime() > date.getTime(); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionDTO.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionDTO.ts new file mode 100644 index 00000000000..85a53feb1b8 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionDTO.ts @@ -0,0 +1,14 @@ +import { Identity } from "~/translations/Identity"; + +export interface GqlTranslatedCollectionDTO { + collectionId: string; + languageCode: string; + items: Array<{ + itemId: string; + baseValue: () => string; + baseValueModifiedOn: () => string; + value?: string; + translatedOn?: string; + translatedBy?: Identity; + }>; +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper.ts new file mode 100644 index 00000000000..cc5465f8e3a --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper.ts @@ -0,0 +1,34 @@ +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { GqlTranslatedCollectionDTO } from "~/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionDTO"; +import { TranslatableCollection } from "~/translations/translatableCollection/domain/TranslatableCollection"; + +export class GqlTranslatedCollectionMapper { + static toDTO( + baseCollection: TranslatableCollection, + translatedCollection: TranslatedCollection + ): GqlTranslatedCollectionDTO { + return { + collectionId: translatedCollection.getCollectionId(), + languageCode: translatedCollection.getLanguageCode(), + items: translatedCollection.getItems().map(item => { + return { + itemId: item.itemId, + baseValue: () => { + return baseCollection.getBaseValue(item.itemId) ?? ""; + }, + baseValueModifiedOn: () => { + const modifiedOn = baseCollection.getItemModifiedOn(item.itemId); + + return modifiedOn ? modifiedOn.toISOString() : ""; + }, + context: () => { + return baseCollection.getItemContext(item.itemId); + }, + value: item.value, + translatedOn: item.translatedOn?.toISOString(), + translatedBy: item.translatedBy + }; + }) + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts new file mode 100644 index 00000000000..0717d5c989a --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts @@ -0,0 +1,81 @@ +import { ErrorResponse, NotFoundResponse, Response } from "@webiny/handler-graphql"; +import type { Resolvers } from "@webiny/handler-graphql/types"; +import type { PbContext } from "~/graphql/types"; +import { GqlTranslatedCollectionMapper } from "~/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper"; +import { SaveTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase"; +import { GetOrCreateTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase"; +import { GetTranslatableCollectionUseCase } from "~/translations"; + +interface GetTranslatedCollectionParams { + collectionId: string; + languageCode: string; +} + +interface UpdateTranslatedCollectionParams { + collectionId: string; + languageCode: string; + items: Array<{ itemId: string; value?: string }>; +} + +export const translatedCollectionResolvers: Resolvers = { + TranslationsQuery: { + getTranslatedCollection: async (_, args, context) => { + try { + const { collectionId, languageCode } = args as GetTranslatedCollectionParams; + + const getBaseCollection = new GetTranslatableCollectionUseCase(context); + const baseCollection = await getBaseCollection.execute(collectionId); + + if (!baseCollection) { + return new NotFoundResponse( + `TranslatableCollection ${collectionId} was not found!` + ); + } + + const getTranslatedCollection = new GetOrCreateTranslatedCollectionUseCase(context); + const translatedCollection = await getTranslatedCollection.execute({ + collectionId, + languageCode + }); + + const translatedCollectionDto = GqlTranslatedCollectionMapper.toDTO( + baseCollection, + translatedCollection + ); + + return new Response(translatedCollectionDto); + } catch (err) { + return new ErrorResponse(err); + } + } + }, + TranslationsMutation: { + saveTranslatedCollection: async (_, args, context) => { + const { collectionId, languageCode, items } = args as UpdateTranslatedCollectionParams; + + try { + const useCase = new SaveTranslatedCollectionUseCase(context); + const collection = await useCase.execute({ + collectionId, + languageCode, + items + }); + + const getBaseCollection = new GetTranslatableCollectionUseCase(context); + const baseCollection = await getBaseCollection.execute(collectionId); + + if (!baseCollection) { + return new NotFoundResponse( + `TranslatableCollection ${collectionId} was not found!` + ); + } + + return new Response( + GqlTranslatedCollectionMapper.toDTO(baseCollection, collection) + ); + } catch (err) { + return new ErrorResponse(err); + } + } + } +}; diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts new file mode 100644 index 00000000000..ca4cdc5a48d --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts @@ -0,0 +1,45 @@ +export const translatedCollectionSchema = /* GraphQL*/ ` + type TranslatedItem { + itemId: String! + baseValue: String! + baseValueModifiedOn: String! + value: String + context: JSON + translatedOn: DateTime + translatedBy: PbIdentity + } + + type TranslatedCollection { + collectionId: ID! + languageCode: String! + items: [TranslatedItem!]! + } + + input TranslatedItemInput { + itemId: String! + value: String + } + + type TranslatedCollectionResponse { + data: TranslatedCollection + error: PbError + } + + type SaveTranslatedCollectionResponse { + data: TranslatedCollection + error: PbError + } + + extend type TranslationsQuery { + """Get the source collection with all the items that need to be translated.""" + getTranslatedCollection(collectionId: ID!, languageCode: String!): TranslatedCollectionResponse + } + + extend type TranslationsMutation { + saveTranslatedCollection( + collectionId: ID! + languageCode: String! + items: [TranslatedItemInput!]! + ): SaveTranslatedCollectionResponse + } +`; diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/CreateTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/CreateTranslatedCollectionRepository.ts new file mode 100644 index 00000000000..9f352f9c694 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/CreateTranslatedCollectionRepository.ts @@ -0,0 +1,22 @@ +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatedCollectionDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { TranslatedCollectionMapper } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper"; + +export class CreateTranslatedCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collection: TranslatedCollection): Promise { + const model = await GetModel.byModelId(this.context, "translatedCollection"); + + await this.context.cms.createEntry( + model, + TranslatedCollectionMapper.toDTO(collection) + ); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts new file mode 100644 index 00000000000..692271ee71a --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts @@ -0,0 +1,40 @@ +import { WebinyError } from "@webiny/error"; +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { TranslatedCollectionDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO"; +import { TranslatedCollectionMapper } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper"; + +interface GetTranslatedCollectionParams { + collectionId: string; + languageCode: string; +} + +export class GetTranslatedCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: GetTranslatedCollectionParams): Promise { + const model = await GetModel.byModelId(this.context, "translatedCollection"); + + const existingEntry = await this.context.cms.getEntry(model, { + where: { + collectionId: params.collectionId, + languageCode: params.languageCode, + latest: true + } + }); + + if (!existingEntry) { + throw new WebinyError({ + message: `TranslatedCollection "${params.collectionId}" for language "${params.languageCode}" was not found!`, + code: "NOT_FOUND" + }); + } + + return TranslatedCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/UpdateTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/UpdateTranslatedCollectionRepository.ts new file mode 100644 index 00000000000..ae0bdb60897 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/UpdateTranslatedCollectionRepository.ts @@ -0,0 +1,33 @@ +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { WebinyError } from "@webiny/error"; +import { createIdentifier } from "@webiny/utils"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { TranslatedCollectionMapper } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper"; + +export class UpdateTranslatedCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collection: TranslatedCollection): Promise { + const model = await GetModel.byModelId(this.context, "translatedCollection"); + const dto = TranslatedCollectionMapper.toDTO(collection); + + if (!dto.id) { + throw new WebinyError({ + message: "Updating a record without an ID is not allowed!", + code: "UPDATE_WITHOUT_ID_NOT_ALLOWED" + }); + } + + const cmsId = createIdentifier({ + id: dto.id, + version: 1 + }); + + await this.context.cms.updateEntry(model, cmsId, dto); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO.ts new file mode 100644 index 00000000000..6a193a93742 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO.ts @@ -0,0 +1,7 @@ +import { TranslatedItemDTO } from "./TranslatedItemDTO"; + +export interface TranslatedCollectionDTO { + collectionId: string; + languageCode: string; + items: TranslatedItemDTO[]; +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper.ts new file mode 100644 index 00000000000..5e2d0875fe2 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedCollectionMapper.ts @@ -0,0 +1,25 @@ +import { TranslatedCollectionDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { TranslatedItemMapper } from "~/translations/translatedCollection/repository/mappers/TranslatedItemMapper"; + +export class TranslatedCollectionMapper { + static fromDTO(dto: TranslatedCollectionDTO, id?: string): TranslatedCollection { + return new TranslatedCollection( + { + collectionId: dto.collectionId, + languageCode: dto.languageCode, + items: dto.items.map(item => TranslatedItemMapper.fromDTO(item)) + }, + id + ); + } + + static toDTO(collection: TranslatedCollection) { + return { + id: collection.getId(), + collectionId: collection.getCollectionId(), + languageCode: collection.getLanguageCode(), + items: collection.getItems().map(item => TranslatedItemMapper.toDTO(item)) + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemDTO.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemDTO.ts new file mode 100644 index 00000000000..60c1dc3190a --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemDTO.ts @@ -0,0 +1,8 @@ +import { Identity } from "~/translations/Identity"; + +export interface TranslatedItemDTO { + itemId: string; + value?: string; + translatedOn?: string; + translatedBy?: Identity; +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemMapper.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemMapper.ts new file mode 100644 index 00000000000..304ae620405 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/mappers/TranslatedItemMapper.ts @@ -0,0 +1,22 @@ +import { TranslatedItemDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedItemDTO"; +import { TranslatedItem } from "~/translations/translatedCollection/domain/TranslatedItem"; + +export class TranslatedItemMapper { + static fromDTO(dto: TranslatedItemDTO) { + return TranslatedItem.create({ + itemId: dto.itemId, + value: dto.value, + translatedOn: dto.translatedOn ? new Date(dto.translatedOn) : undefined, + translatedBy: dto.translatedBy + }); + } + + static toDTO(item: TranslatedItem): TranslatedItemDTO { + return { + itemId: item.itemId, + value: item.value, + translatedOn: item.translatedOn?.toISOString(), + translatedBy: item.translatedBy + }; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/translatedCollection.model.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/translatedCollection.model.ts new file mode 100644 index 00000000000..655d56514a1 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/translatedCollection.model.ts @@ -0,0 +1,95 @@ +import { createPrivateModel } from "@webiny/api-headless-cms"; + +export const translatedCollectionModel = createPrivateModel({ + name: "Translated Collection", + modelId: "translatedCollection", + titleFieldId: "collectionId", + fields: [ + { + id: "collectionId", + fieldId: "collectionId", + storageId: "text@collectionId", + type: "text", + label: "Collection ID", + tags: [], + multipleValues: false, + validation: [ + { + name: "required", + settings: {}, + message: "Value is required." + } + ] + }, + { + id: "items", + fieldId: "items", + storageId: "object@items", + type: "object", + label: "Items", + tags: [], + multipleValues: true, + validation: [], + settings: { + fields: [ + { + label: "Item ID", + id: "itemId", + type: "text", + validation: [ + { + name: "required", + message: "Value is required." + } + ], + fieldId: "itemId", + storageId: "text@itemId" + }, + { + label: "Value", + id: "value", + type: "long-text", + validation: [], + fieldId: "value", + storageId: "long-text@value" + }, + { + settings: { + type: "dateTimeWithTimezone" + }, + label: "Translated On", + id: "translatedOn", + type: "datetime", + validation: [], + fieldId: "translatedOn", + storageId: "datetime@translatedOn" + }, + { + label: "Translated By", + id: "translatedBy", + type: "json", + validation: [], + fieldId: "translatedBy", + storageId: "json@translatedBy" + } + ] + } + }, + { + id: "languageCode", + fieldId: "languageCode", + storageId: "text@languageCode", + type: "text", + label: "Language Code", + tags: [], + multipleValues: false, + validation: [ + { + name: "required", + settings: {}, + message: "Value is required." + } + ] + } + ] +}); diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/CloneTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/CloneTranslatedCollectionUseCase.ts new file mode 100644 index 00000000000..1a05c125494 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/CloneTranslatedCollectionUseCase.ts @@ -0,0 +1,41 @@ +import { PbContext } from "~/graphql/types"; +import { GetTranslatedCollectionUseCase, SaveTranslatedCollectionUseCase } from "~/translations"; +import { TranslatedCollection } from "../domain/TranslatedCollection"; + +interface CloneTranslatableCollectionParams { + sourceCollectionId: string; + newCollectionId: string; + languageCode: string; +} + +export class CloneTranslatedCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute({ + sourceCollectionId, + newCollectionId, + languageCode + }: CloneTranslatableCollectionParams): Promise { + // Clone the translated collection. + const getTranslatedCollection = new GetTranslatedCollectionUseCase(this.context); + const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(this.context); + + const baseTranslatedCollection = await getTranslatedCollection.execute({ + collectionId: sourceCollectionId, + languageCode: languageCode + }); + + return await saveTranslatedCollection.execute({ + collectionId: newCollectionId, + languageCode: languageCode, + items: baseTranslatedCollection.getItems().map(item => ({ + itemId: item.itemId, + value: item.value + })) + }); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase.ts new file mode 100644 index 00000000000..3ca71ca0bbc --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase.ts @@ -0,0 +1,58 @@ +import { PbContext } from "~/types"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { GetTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/GetTranslatedCollectionUseCase"; +import { GetOrCreateTranslatableCollectionUseCase } from "~/translations/translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase"; +import { TranslatedItem } from "~/translations/translatedCollection/domain/TranslatedItem"; + +interface GetTranslatedCollectionParams { + collectionId: string; + languageCode: string; +} + +export class GetOrCreateTranslatedCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: GetTranslatedCollectionParams): Promise { + // Get base collection. + const getBaseCollection = new GetOrCreateTranslatableCollectionUseCase(this.context); + const baseCollection = await getBaseCollection.execute(params.collectionId); + + let translatedCollection: TranslatedCollection; + + try { + const getTranslatedCollection = new GetTranslatedCollectionUseCase(this.context); + translatedCollection = await getTranslatedCollection.execute(params); + } catch (err) { + if (err.code !== "NOT_FOUND") { + throw err; + } + + translatedCollection = new TranslatedCollection({ + collectionId: params.collectionId, + languageCode: params.languageCode, + items: [] + }); + } + + // Make sure the `TranslatedCollection` is in sync with its base `TranslatableCollection`. + const translatedItems = translatedCollection.getItems(); + + const syncItems = baseCollection.getItems().map(bi => { + const matchingItem = translatedItems.find(ti => ti.itemId === bi.itemId); + + if (matchingItem) { + return matchingItem; + } + + return TranslatedItem.create({ itemId: bi.itemId }); + }); + + translatedCollection.setItems(syncItems); + + return translatedCollection; + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/GetTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/GetTranslatedCollectionUseCase.ts new file mode 100644 index 00000000000..85945239b5f --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/GetTranslatedCollectionUseCase.ts @@ -0,0 +1,21 @@ +import { PbContext } from "~/types"; +import { GetTranslatedCollectionRepository } from "~/translations/translatedCollection/repository/GetTranslatedCollectionRepository"; +import type { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; + +interface GetTranslatedCollectionParams { + collectionId: string; + languageCode: string; +} + +export class GetTranslatedCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: GetTranslatedCollectionParams): Promise { + const getCollection = new GetTranslatedCollectionRepository(this.context); + return await getCollection.execute(params); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase.ts new file mode 100644 index 00000000000..621ed88ec03 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase.ts @@ -0,0 +1,72 @@ +import { PbContext } from "~/graphql/types"; +import { Identifier } from "~/translations/Identifier"; +import { TranslatedCollection } from "~/translations/translatedCollection/domain/TranslatedCollection"; +import { GetOrCreateTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase"; +import { UpdateTranslatedCollectionRepository } from "~/translations/translatedCollection/repository/UpdateTranslatedCollectionRepository"; +import { CreateTranslatedCollectionRepository } from "~/translations/translatedCollection/repository/CreateTranslatedCollectionRepository"; +import { TranslatedItem } from "~/translations/translatedCollection/domain/TranslatedItem"; + +interface SaveTranslatedCollectionParams { + collectionId: string; + languageCode: string; + items: Array<{ + itemId: string; + translatedOn?: string; + value?: string; + }>; +} + +export class SaveTranslatedCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: SaveTranslatedCollectionParams): Promise { + const getOrCreate = new GetOrCreateTranslatedCollectionUseCase(this.context); + const collection = await getOrCreate.execute({ + collectionId: params.collectionId, + languageCode: params.languageCode + }); + + const identity = this.getIdentity(); + + const newItems = params.items.map(item => { + // If a value was unset, we want to unset the translatedOn and translatedBy. + const value = item.value ? item.value : undefined; + + const translatedOn = item.translatedOn ? new Date(item.translatedOn) : undefined; + + return TranslatedItem.create({ + itemId: item.itemId, + value, + translatedOn: translatedOn ?? value ? new Date() : undefined, + translatedBy: value ? identity : undefined + }); + }); + + collection.updateItems(newItems); + + if (collection.getId()) { + const update = new UpdateTranslatedCollectionRepository(this.context); + await update.execute(collection); + } else { + collection.setId(Identifier.generate()); + const create = new CreateTranslatedCollectionRepository(this.context); + await create.execute(collection); + } + + return collection; + } + + private getIdentity() { + const identity = this.context.security.getIdentity(); + + return { + id: identity.id, + type: identity.type, + displayName: identity.displayName || "" + }; + } +} diff --git a/packages/app-admin/src/hooks/index.ts b/packages/app-admin/src/hooks/index.ts index 0c3aedd89a2..386162ddde0 100644 --- a/packages/app-admin/src/hooks/index.ts +++ b/packages/app-admin/src/hooks/index.ts @@ -8,4 +8,3 @@ export * from "./useStateWithCallback"; export * from "./useModKey"; export * from "./useIsMounted"; export * from "./useStateIfMounted"; -export * from "./createGenericContext"; diff --git a/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx b/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx index 41f50960f4a..69fcbdb33cd 100644 --- a/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx +++ b/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx @@ -12,7 +12,9 @@ import { LOG_FORM_VIEW } from "@webiny/app-page-builder-elements/renderers/form/dataLoaders/graphql"; -const PeForm: FormRenderer = props => { +type PeFormProps = React.ComponentProps; + +const PeForm = (props: PeFormProps) => { // We wrap the original renderer in order to be able to provide the Apollo client. const apolloClient = useApolloClient(); diff --git a/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx b/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx index 81bf01ad74c..942494bcae8 100644 --- a/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx +++ b/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx @@ -12,7 +12,9 @@ import { GET_PUBLISHED_FORM } from "@webiny/app-page-builder-elements/renderers/form/dataLoaders/graphql"; -const PeForm: FormRenderer = props => { +type PeFormProps = React.ComponentProps; + +const PeForm = (props: PeFormProps) => { // We wrap the original renderer in order to be able to provide the Apollo client. const apolloClient = useApolloClient(); diff --git a/packages/app-page-builder-elements/package.json b/packages/app-page-builder-elements/package.json index 0eb619dae12..5d5fa55ad63 100644 --- a/packages/app-page-builder-elements/package.json +++ b/packages/app-page-builder-elements/package.json @@ -17,9 +17,10 @@ "@babel/runtime": "^7.24.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", - "@webiny/lexical-editor": "0.0.0", + "@webiny/react-composition": "0.0.0", "@webiny/theme": "0.0.0", - "facepaint": "^1.2.1" + "facepaint": "^1.2.1", + "lodash": "^4.17.21" }, "peerDependencies": { "@editorjs/editorjs": "^2.20.1", diff --git a/packages/app-page-builder-elements/src/contexts/ElementRendererInputs.tsx b/packages/app-page-builder-elements/src/contexts/ElementRendererInputs.tsx new file mode 100644 index 00000000000..96889e4320f --- /dev/null +++ b/packages/app-page-builder-elements/src/contexts/ElementRendererInputs.tsx @@ -0,0 +1,37 @@ +import React, { createContext, useContext } from "react"; +import { makeDecoratable } from "@webiny/react-composition"; +import { Element } from "~/types"; +import { ElementInputs as TElementInputs, ElementInputValues } from "~/inputs/ElementInput"; + +type ElementRendererInputsContext = + ElementInputValues; + +const ElementRendererInputsContext = createContext( + undefined +); + +export interface ElementRendererInputsProps { + element: Element; + inputs: TElementInputs | undefined; + values: ElementInputValues; + children: React.ReactNode; +} + +const BaseElementInputs = ({ children, values }: ElementRendererInputsProps) => { + return ( + + {children} + + ); +}; + +export const ElementRendererInputs = makeDecoratable("ElementRendererInputs", BaseElementInputs); + +export function useElementInputs() { + const context = useContext(ElementRendererInputsContext); + if (!context) { + throw new Error(`Missing provider in the component hierarchy!`); + } + + return context; +} diff --git a/packages/app-page-builder-elements/src/contexts/Renderer.tsx b/packages/app-page-builder-elements/src/contexts/Renderer.tsx index c0dc67bfa40..78db6744db6 100644 --- a/packages/app-page-builder-elements/src/contexts/Renderer.tsx +++ b/packages/app-page-builder-elements/src/contexts/Renderer.tsx @@ -1,10 +1,10 @@ import React, { createContext } from "react"; import { usePageElements } from "~/hooks/usePageElements"; -import { RendererContextValue, RendererProviderProps } from "~/types"; +import { GetElement, RendererContextValue, RendererProviderProps } from "~/types"; +import { ElementInputs, ElementInputValues } from "~/inputs/ElementInput"; +import { useElementInputs } from "~/contexts/ElementRendererInputs"; -export const RendererContext = createContext( - null as unknown as RendererContextValue -); +export const RendererContext = createContext(undefined); export const RendererProvider = ({ children, @@ -12,13 +12,24 @@ export const RendererProvider = ({ attributes, meta }: RendererProviderProps) => { - const getElement = () => element; + const inputValues = useElementInputs(); + const getElement = (() => element) as GetElement; const getAttributes = () => attributes; + const getInputValues = () => { + return inputValues as ElementInputValues; + }; const pageElements = usePageElements(); - // @ts-expect-error Resolve the `getElement` issue. - const value: RendererContextValue = { ...pageElements, getElement, getAttributes, meta }; + const value: RendererContextValue = { + ...pageElements, + beforeRenderer: pageElements.beforeRenderer ?? null, + afterRenderer: pageElements.beforeRenderer ?? null, + getElement, + getAttributes, + getInputValues, + meta + }; return {children}; }; diff --git a/packages/app-page-builder-elements/src/createRenderer.tsx b/packages/app-page-builder-elements/src/createRenderer.tsx index 68561456e2b..5cf49eccbc9 100644 --- a/packages/app-page-builder-elements/src/createRenderer.tsx +++ b/packages/app-page-builder-elements/src/createRenderer.tsx @@ -1,19 +1,29 @@ -import React from "react"; +import React, { useMemo } from "react"; +import { Theme, StylesObject } from "@webiny/theme/types"; +import { CSSObject, ClassNames } from "@emotion/react"; +import { GenericComponent, makeDecoratable } from "@webiny/react-composition"; import { usePageElements } from "~/hooks/usePageElements"; -import { Element, Renderer } from "~/types"; -import { StylesObject, Theme } from "@webiny/theme/types"; +import { Renderer, Element } from "~/types"; import { RendererProvider } from "~/contexts/Renderer"; -import { ClassNames, CSSObject } from "@emotion/react"; +import { ElementInput, ElementInputs, ElementInputValues } from "~/inputs/ElementInput"; +import { ElementRendererInputs } from "~/contexts/ElementRendererInputs"; interface GetStylesParams { theme: Theme; element: Element; } -export type CreateRendererOptions = Partial<{ - propsAreEqual: (prevProps: TRenderComponentProps, nextProps: TRenderComponentProps) => boolean; +export type CreateRendererOptions< + TRenderComponentProps, + TInputs = Record +> = Partial<{ + propsAreEqual: ( + prevProps: TRenderComponentProps & Inputs, + nextProps: TRenderComponentProps & Inputs + ) => boolean; themeStyles: StylesObject | ((params: GetStylesParams) => StylesObject); baseStyles: StylesObject | ((params: GetStylesParams) => StylesObject); + inputs: TInputs; }>; const DEFAULT_RENDERER_STYLES: StylesObject = { @@ -23,11 +33,34 @@ const DEFAULT_RENDERER_STYLES: StylesObject = { boxSizing: "border-box" }; -export function createRenderer>( - RendererComponent: React.ComponentType, - options: CreateRendererOptions = {} -): Renderer { - return function Renderer(props) { +const EMPTY_OBJECT = {}; + +export type Inputs = { + inputs?: { [K in keyof T]?: T[K] extends ElementInput ? P : never }; +}; + +type DecoratableComponent = ReturnType>; + +export function createRenderer< + TRenderComponentProps = Record, + TInputs extends ElementInputs = ElementInputs +>( + RendererComponent: React.FunctionComponent, + options: CreateRendererOptions = {} +): DecoratableComponent>> & { + Component: DecoratableComponent; + inputs?: TInputs; +} { + // We need to make the renderer component decoratable, to allow developers to conditionally render different + // output depending on the renderer inputs. + const DecoratableRendererComponent = makeDecoratable( + "DecoratableRendererComponent", + RendererComponent + ); + + function ConcreteRenderer( + props: React.ComponentProps>> + ) { const { getElementStyles, getStyles, @@ -42,7 +75,7 @@ export function createRenderer>( return null; } - const { element, meta, ...componentProps } = props; + const { element, meta, inputs, ...componentProps } = props; const attributes = getElementAttributes(element); const styles: CSSObject[] = [DEFAULT_RENDERER_STYLES]; @@ -77,6 +110,19 @@ export function createRenderer>( // Styles applied via registered styles modifiers (applied via the PB editor's right sidebar). styles.push(getElementStyles(element)); + // Calculate input values using props and fallback values from `element.data`. + const inputValues = useMemo(() => { + const elementInputs: ElementInputs = options.inputs || EMPTY_OBJECT; + const inputValues = (inputs || EMPTY_OBJECT) as ElementInputValues; + + return Object.entries(elementInputs).reduce((values, [key, input]) => { + const inputValue = + key in inputValues ? inputValues[key] : input.getDefaultValue(element); + + return { ...values, [key]: inputValue }; + }, {}); + }, [element, inputs]); + return ( {({ css }) => { @@ -87,30 +133,63 @@ export function createRenderer>( const o = [css(styles), attributes.class].filter(Boolean).join(" "); return ( - - {React.createElement( - `pb-${element.type}`, - { ...attributes, class: o }, - <> - {BeforeRenderer ? : null} - - {/* - Would've liked if the `as unknown as any` part wasn't - needed, but unfortunately, could not figure it out. - // TODO remove any! - */} - - {AfterRenderer ? : null} - - )} - + + {React.createElement( + `pb-${element.type}`, + { ...attributes, class: o }, + <> + {BeforeRenderer ? : null} + + {AfterRenderer ? : null} + + )} + + ); }} ); - }; + } + + /** + * Wrap the concrete element renderer with an extra element, to allow decoration. + * This allows developers to intercept props, replace the actual renderer, hide the element, etc. + */ + function Renderer( + props: React.ComponentProps>> + ) { + return ; + } + + return Object.assign(makeDecoratable("ElementRenderer", Renderer), { + Component: DecoratableRendererComponent, + inputs: options.inputs + }); } + +const BaseElementRenderer = ( + props: React.ComponentProps & Inputs>> & { + renderer: React.ComponentType; + } +) => { + const { renderer, ...rest } = props; + + return React.createElement(renderer, rest); +}; + +/** + * This component allows developers to intercept all element renderers using a single decorator. + */ +export const ElementRenderer = makeDecoratable("ElementRenderer", BaseElementRenderer); diff --git a/packages/app-page-builder-elements/src/hooks/useRenderer.ts b/packages/app-page-builder-elements/src/hooks/useRenderer.ts index e6fab17224d..9c5c49ff646 100644 --- a/packages/app-page-builder-elements/src/hooks/useRenderer.ts +++ b/packages/app-page-builder-elements/src/hooks/useRenderer.ts @@ -1,7 +1,12 @@ import { useContext } from "react"; import { RendererContext } from "~/contexts/Renderer"; -import { RendererContextValue } from "~/types"; -export function useRenderer(): RendererContextValue { - return useContext(RendererContext); +export function useRenderer() { + const context = useContext(RendererContext); + + if (!context) { + throw Error(`Missing "RendererProvider" context provider in the component hierarchy!`); + } + + return context; } diff --git a/packages/app-page-builder-elements/src/index.ts b/packages/app-page-builder-elements/src/index.ts index 113833e60b0..4865e5ee8f2 100644 --- a/packages/app-page-builder-elements/src/index.ts +++ b/packages/app-page-builder-elements/src/index.ts @@ -10,8 +10,12 @@ export * from "./hooks/useFacepaint"; export * from "./contexts/PageElements"; export * from "./contexts/Page"; export * from "./contexts/Renderer"; +export * from "./contexts/ElementRendererInputs"; export * from "./components/Page"; export * from "./components/Element"; export * from "./components/Elements"; export * from "./components/ErrorBoundary"; + +export * from "./inputs/ElementInput"; +export type { DecoratableRenderer } from "./types"; diff --git a/packages/app-page-builder-elements/src/inputs/ElementInput.ts b/packages/app-page-builder-elements/src/inputs/ElementInput.ts new file mode 100644 index 00000000000..dd3378779c8 --- /dev/null +++ b/packages/app-page-builder-elements/src/inputs/ElementInput.ts @@ -0,0 +1,64 @@ +import { Element } from "~/types"; + +export interface GetElementInputValueParams { + element: Element; +} + +export interface GetElementInputValue { + (element: GetElementInputValueParams): TValue | undefined; +} + +export type ElementInputs = Record; + +export type ElementInputType = + | "text" + | "number" + | "boolean" + | "date" + | "richText" + | "link" + | "svgIcon" + | "color" + // We want to allow custom strings as well. + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {}); + +export interface ElementInputParams { + name: string; + type: ElementInputType; + getDefaultValue: GetElementInputValue; + translatable?: boolean; +} + +export class ElementInput { + private params: ElementInputParams; + + private constructor(params: ElementInputParams) { + this.params = params; + } + + static create(params: ElementInputParams) { + return new ElementInput(params); + } + + getType() { + return this.params.type; + } + + isTranslatable() { + return this.params.translatable ?? false; + } + + getDefaultValue(element: Element): TValue | undefined { + const value = this.params.getDefaultValue({ element }); + if (!value) { + return undefined; + } + + return value as TValue; + } +} + +export type ElementInputValues = { + [K in keyof T]: T[K] extends ElementInput ? P | undefined : never; +}; diff --git a/packages/app-page-builder-elements/src/renderers/block.tsx b/packages/app-page-builder-elements/src/renderers/block.tsx index 1655538112f..8bb210536bc 100644 --- a/packages/app-page-builder-elements/src/renderers/block.tsx +++ b/packages/app-page-builder-elements/src/renderers/block.tsx @@ -2,32 +2,34 @@ import React from "react"; import { Elements } from "~/components/Elements"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; +import { makeDecoratable } from "@webiny/react-composition"; +import { BlockProvider } from "./block/BlockProvider"; +export * from "./block/BlockProvider"; -export type BlockRenderer = ReturnType; +const BaseBlockRenderer = createRenderer( + () => { + const { getElement } = useRenderer(); -export const createBlock = () => { - return createRenderer( - () => { - const { getElement } = useRenderer(); + const element = getElement(); - const element = getElement(); - return ( - <> - - {element.data.blockId && ( - - )} - - ); - }, - { - baseStyles: { - display: "flex", - flexDirection: "column", - justifyContent: "flex-start", - alignItems: "flex-start", - boxSizing: "border-box" - } + return ( + + + {element.data.blockId && ( + + )} + + ); + }, + { + baseStyles: { + display: "flex", + flexDirection: "column", + justifyContent: "flex-start", + alignItems: "flex-start", + boxSizing: "border-box" } - ); -}; + } +); + +export const BlockRenderer = makeDecoratable("BlockRenderer", BaseBlockRenderer); diff --git a/packages/app-page-builder-elements/src/renderers/block/BlockProvider.tsx b/packages/app-page-builder-elements/src/renderers/block/BlockProvider.tsx new file mode 100644 index 00000000000..88e4c9de43e --- /dev/null +++ b/packages/app-page-builder-elements/src/renderers/block/BlockProvider.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Element } from "~/types"; +import { makeDecoratable } from "@webiny/react-composition"; + +interface BlockProviderProps { + block: Element | null; + children: React.ReactNode; +} + +const BlockContext = React.createContext(null); + +export const BlockProvider = ({ block, children }: BlockProviderProps) => { + return {children}; +}; + +export const useParentBlock = makeDecoratable(() => { + const context = React.useContext(BlockContext); + + if (context === undefined) { + throw new Error(` is missing in the component hierarchy!`); + } + + return context; +}); diff --git a/packages/app-page-builder-elements/src/renderers/button.tsx b/packages/app-page-builder-elements/src/renderers/button.tsx index c44c354baaf..fd5d1299f99 100644 --- a/packages/app-page-builder-elements/src/renderers/button.tsx +++ b/packages/app-page-builder-elements/src/renderers/button.tsx @@ -1,11 +1,13 @@ import React, { useMemo } from "react"; -import { usePageElements } from "~/hooks/usePageElements"; -import { LinkComponent } from "~/types"; import styled, { CSSObject } from "@emotion/styled"; import { ClassNames } from "@emotion/react"; +import isEqual from "lodash/isEqual"; +import { usePageElements } from "~/hooks/usePageElements"; +import { LinkComponent } from "~/types"; import { DefaultLinkComponent } from "~/renderers/components"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; +import { ElementInput } from "~/inputs/ElementInput"; const ICON_POSITION_FLEX_DIRECTION: Record = { right: { flexDirection: "row-reverse" }, @@ -32,11 +34,6 @@ export interface ButtonClickHandler { }>; } -export interface CreateButtonParams { - linkComponent?: LinkComponent; - clickHandlers?: Array | (() => Array); -} - interface ButtonBodyProps { className?: string; children?: React.ReactNode; @@ -77,8 +74,6 @@ const ButtonText = ({ text }: ButtonTextProps) => { return
{text}
; }; -export type ButtonRenderer = ReturnType; - export interface ButtonElementData { buttonText: string; link: { @@ -97,125 +92,177 @@ export interface ButtonElementData { } export interface Props { - buttonText?: string; - action?: ButtonElementData["action"]; + linkComponent?: LinkComponent; + clickHandlers?: Array; } -export const createButton = (params: CreateButtonParams = {}) => { - const LinkComponent = params?.linkComponent || DefaultLinkComponent; - - return createRenderer( - props => { - const { getStyles } = usePageElements(); - const { getElement } = useRenderer(); - const element = getElement(); - const { link, icon } = element.data; - - const buttonText = props.buttonText || element.data.buttonText; - const action = props.action?.href ? props.action : element.data.action; - - let buttonInnerContent = ; - - let StyledButtonBody = ButtonBody, - StyledButtonIcon; - - if (icon && icon.svg) { - const { position = "left", color } = icon; - - StyledButtonBody = styled(StyledButtonBody)({ - display: "flex", - ...ICON_POSITION_FLEX_DIRECTION[position] - }) as (props: ButtonBodyProps) => JSX.Element; - - StyledButtonIcon = styled(ButtonIcon)( - { - width: icon.width, - ...ICON_POSITION_MARGIN[position] - }, - getStyles(theme => { - const themeColor = theme.styles.colors?.[color]; - return { - color: themeColor || color - }; - }) - ); - - buttonInnerContent = ( - <> - - {buttonInnerContent} - - ); - } +export const elementInputs = { + buttonText: ElementInput.create({ + name: "buttonText", + translatable: true, + type: "text", + getDefaultValue: ({ element }) => { + return element.data.buttonText; + } + }), + iconPosition: ElementInput.create({ + name: "iconPosition", + type: "text", + getDefaultValue: ({ element }) => { + return element.data.icon?.position; + } + }), + iconColor: ElementInput.create({ + name: "iconColor", + type: "color", + getDefaultValue: ({ element }) => { + return element.data.icon?.color; + } + }), + iconSvg: ElementInput.create({ + name: "iconSvg", + type: "svgIcon", + getDefaultValue: ({ element }) => { + return element.data.icon?.svg; + } + }), + iconWidth: ElementInput.create({ + name: "iconWidth", + type: "number", + getDefaultValue: ({ element }) => { + return element.data.icon?.width; + } + }), + actionType: ElementInput.create({ + name: "actionType", + type: "text", + getDefaultValue: ({ element }) => { + return element.data.action?.actionType; + } + }), + actionNewTab: ElementInput.create({ + name: "actionNewTab", + type: "boolean", + getDefaultValue: ({ element }) => { + return element.data.action?.newTab; + } + }), + actionHref: ElementInput.create({ + name: "actionHref", + type: "link", + translatable: true, + getDefaultValue: ({ element }) => { + return element.data.action?.href; + } + }) +}; - // The `link` property is a legacy property, and it's not used anymore, - // but we still need to support it in order to not break existing pages. - const isLinkAction = useMemo(() => { - return link?.href || ["link", "scrollToElement"].includes(action?.actionType); - }, [link?.href, action?.actionType]); - - if (isLinkAction) { - let href = ""; - - // In case the `action.actionType` is `scrollToElement`, the flag will remain false. - let newTab = false; - - if (link?.href) { - href = link.href; - newTab = link?.newTab; - } else { - if (action.actionType === "link") { - href = action.href; - newTab = action.newTab; - } - - if (action.actionType === "scrollToElement") { - href = "#" + action.scrollToElement; - } - } +export const ButtonRenderer = createRenderer( + props => { + const LinkComponent = props.linkComponent || DefaultLinkComponent; + const { getStyles } = usePageElements(); + const { getElement, getInputValues } = useRenderer(); + const element = getElement(); + const inputs = getInputValues(); + const { link } = element.data; - return ( - - {buttonInnerContent} - - ); - } + const buttonText = inputs.buttonText || ""; + let buttonInnerContent = ; + + const action: ButtonElementData["action"] = { + href: inputs.actionHref || "", + newTab: inputs.actionNewTab || false, + actionType: inputs.actionType || "link" + }; + + let StyledButtonBody = ButtonBody, + StyledButtonIcon; + + if (inputs.iconSvg) { + const position = inputs.iconPosition || "left"; + const color = inputs.iconColor || "#000"; - let clickHandler: ButtonClickHandler["handler"] | undefined; - if (action?.clickHandler) { - let clickHandlers: Array = []; - if (params?.clickHandlers) { - if (typeof params.clickHandlers === "function") { - clickHandlers = params.clickHandlers(); - } else { - clickHandlers = params.clickHandlers; - } + StyledButtonBody = styled(StyledButtonBody)({ + display: "flex", + ...ICON_POSITION_FLEX_DIRECTION[position] + }) as (props: ButtonBodyProps) => JSX.Element; + + StyledButtonIcon = styled(ButtonIcon)( + { + width: inputs.iconWidth, + ...ICON_POSITION_MARGIN[position] + }, + getStyles(theme => { + const themeColor = theme.styles.colors?.[color]; + return { + color: themeColor || color + }; + }) + ); + + buttonInnerContent = ( + <> + + {buttonInnerContent} + + ); + } + + // The `link` property is a legacy property, and it's not used anymore, + // but we still need to support it in order to not break existing pages. + const isLinkAction = useMemo(() => { + return link?.href || ["link", "scrollToElement"].includes(action?.actionType); + }, [link?.href, action?.actionType]); + + if (isLinkAction) { + let href = ""; + + // In case the `action.actionType` is `scrollToElement`, the flag will remain false. + let newTab = false; + + if (link?.href) { + href = link.href; + newTab = link?.newTab; + } else { + if (action.actionType === "link") { + href = action.href; + newTab = action.newTab; } - clickHandler = clickHandlers?.find( - item => item.id === action?.clickHandler - )?.handler; + if (action.actionType === "scrollToElement") { + href = "#" + action.scrollToElement; + } } return ( - clickHandler?.({ variables: element.data.action.variables! })} - > - {buttonInnerContent} - + + {buttonInnerContent} + ); - }, - { - themeStyles({ theme, element }) { - const { type } = element.data; - return theme.styles.elements?.button[type]; - }, - propsAreEqual: (prevProps: Props, nextProps: Props) => { - return ( - prevProps.buttonText === nextProps.buttonText && - prevProps.action === nextProps.action - ); - } } - ); -}; + + if (action?.clickHandler) { + const clickHandler = props.clickHandlers?.find( + item => item.id === action?.clickHandler + ); + + const onClick = clickHandler + ? () => clickHandler.handler({ variables: element.data.action.variables! }) + : () => void 0; + + return {buttonInnerContent}; + } + + return {buttonInnerContent}; + }, + { + themeStyles({ theme, element }) { + const { type } = element.data; + return theme.styles.elements?.button[type]; + }, + propsAreEqual: (prevProps, nextProps) => { + return isEqual(prevProps.inputs, nextProps.inputs); + }, + inputs: elementInputs + } +); diff --git a/packages/app-page-builder-elements/src/renderers/heading.tsx b/packages/app-page-builder-elements/src/renderers/heading.tsx index 31c9c5aaef9..16b2b18832a 100644 --- a/packages/app-page-builder-elements/src/renderers/heading.tsx +++ b/packages/app-page-builder-elements/src/renderers/heading.tsx @@ -1,37 +1,39 @@ import React from "react"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; -import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor"; -import { usePageElements } from "~/hooks/usePageElements"; -import { assignStyles } from "~/utils"; +import { ElementInput } from "~/inputs/ElementInput"; -export type HeadingRenderer = ReturnType; - -export const createHeading = () => { - return createRenderer(() => { - const { getElement } = useRenderer(); - const element = getElement(); - const { theme } = usePageElements(); +export const elementInputs = { + text: ElementInput.create({ + name: "text", + type: "richText", + translatable: true, + getDefaultValue: ({ element }) => { + return element.data.text.data.text; + } + }), + /** + * `tag` is an element input which exists for backwards compatibility with older rich-text implementations. + */ + tag: ElementInput.create({ + name: "tag", + type: "htmlTag", + getDefaultValue: ({ element }) => { + return element.data.text.desktop.tag; + } + }) +}; - const tag = element.data.text.desktop.tag || "h1"; - const __html = element.data.text.data.text; +export const HeadingRenderer = createRenderer( + () => { + const { getInputValues } = useRenderer(); + const inputs = getInputValues(); + const __html = inputs.text || ""; + const tag = inputs.tag || "h1"; - if (isValidLexicalData(__html)) { - return ( - { - return assignStyles({ - breakpoints: theme.breakpoints, - styles - }); - }} - value={__html} - /> - ); - } return React.createElement(tag, { dangerouslySetInnerHTML: { __html } }); - }, {}); -}; + }, + { inputs: elementInputs } +); diff --git a/packages/app-page-builder-elements/src/renderers/pagesList/index.tsx b/packages/app-page-builder-elements/src/renderers/pagesList/index.tsx index 008eb4ef8fc..3354523f7c7 100644 --- a/packages/app-page-builder-elements/src/renderers/pagesList/index.tsx +++ b/packages/app-page-builder-elements/src/renderers/pagesList/index.tsx @@ -30,7 +30,7 @@ export type PagesListRenderer = ReturnType; export const createPagesList = (params: CreatePagesListParams) => { const { dataLoader, pagesListComponents } = params; - const RendererComponent = createRenderer(() => { + return createRenderer(() => { const { getElement } = useRenderer(); const pageContext = useOptionalPage(); const exclude = pageContext ? [pageContext.page.path] : []; @@ -146,8 +146,4 @@ export const createPagesList = (params: CreatePagesListParams) => { /> ); }); - - Object.assign(RendererComponent, { params }); - - return RendererComponent; }; diff --git a/packages/app-page-builder-elements/src/renderers/paragraph.tsx b/packages/app-page-builder-elements/src/renderers/paragraph.tsx index f562b16c946..777210907e1 100644 --- a/packages/app-page-builder-elements/src/renderers/paragraph.tsx +++ b/packages/app-page-builder-elements/src/renderers/paragraph.tsx @@ -1,31 +1,28 @@ import React from "react"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; -import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor"; -import { usePageElements } from "~/hooks/usePageElements"; -import { assignStyles } from "~/utils"; +import { ElementInput } from "~/inputs/ElementInput"; -export const createParagraph = () => { - return createRenderer(() => { - const { getElement } = useRenderer(); - const element = getElement(); - const { theme } = usePageElements(); - - const __html = element.data.text.data.text; - if (isValidLexicalData(__html)) { - return ( - { - return assignStyles({ - breakpoints: theme.breakpoints, - styles - }); - }} - value={__html} - /> - ); +export const elementInputs = { + text: ElementInput.create({ + name: "text", + type: "richText", + translatable: true, + getDefaultValue: ({ element }) => { + return element.data.text.data.text; } + }) +}; + +/** + * This renderer works with plain HTML. In the past, we used to have the MediumEditor, and it produced plain HTML. + * For the new Lexical Editor, we decorate this renderer from the `@webiny/app-page-builder` package. + */ +export const ParagraphRenderer = createRenderer( + () => { + const { getInputValues } = useRenderer(); + const inputs = getInputValues(); + const __html = inputs.text || ""; // If the text already contains `p` tags (happens when c/p-ing text into the editor), // we don't want to wrap it with another pair of `p` tag. @@ -39,5 +36,6 @@ export const createParagraph = () => { } return

; - }); -}; + }, + { inputs: elementInputs } +); diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index cdc30e46dd3..a96ec8f6a17 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -1,8 +1,11 @@ +import type { createRenderer } from "~/createRenderer"; + export * from "@webiny/theme/types"; import React, { HTMLAttributes } from "react"; import { type CSSObject } from "@emotion/react"; import { StylesObject, ThemeBreakpoints, Theme } from "@webiny/theme/types"; +import { ElementInputs, ElementInputValues } from "~/inputs/ElementInput"; export interface Page { id: string; @@ -23,7 +26,7 @@ export interface Element> { export interface PageElementsProviderProps { theme: Theme; - renderers: Record | (() => Record); + renderers: Record | (() => Record); modifiers: { styles: Record; attributes: Record; @@ -35,7 +38,7 @@ export interface PageElementsProviderProps { export type AttributesObject = React.ComponentProps; -export type GetRenderers = () => Record; +export type GetRenderers = () => Record; export type GetElementAttributes = (element: Element) => AttributesObject; export type GetElementStyles = (element: Element) => CSSObject; export type GetStyles = (styles: StylesObject | ((theme: Theme) => StylesObject)) => CSSObject; @@ -89,12 +92,13 @@ export interface PageElementsContextValue extends PageElementsProviderProps { setStylesCallback: SetStylesCallback; } -type GetElement = >() => Element; -type GetAttributes = () => HTMLAttributes; +export type GetElement = >() => Element; +export type GetAttributes = () => HTMLAttributes; export interface RendererContextValue extends PageElementsContextValue { getElement: GetElement; getAttributes: GetAttributes; + getInputValues: () => ElementInputValues; beforeRenderer: React.ComponentType | null; afterRenderer: React.ComponentType | null; meta: RendererProviderMeta; @@ -128,7 +132,10 @@ export interface PageProviderProps { export type Renderer< T = Record, TElementData = Record -> = React.ComponentType & T>; +> = React.FunctionComponent & T>; + +// TODO: maybe call this `Renderer` but rename the base one to `BaseRenderer` ? +export type DecoratableRenderer = ReturnType; export type ElementAttributesModifier = (args: { element: Element; diff --git a/packages/app-page-builder-elements/tsconfig.build.json b/packages/app-page-builder-elements/tsconfig.build.json index 58a9bc11a38..674c1c32099 100644 --- a/packages/app-page-builder-elements/tsconfig.build.json +++ b/packages/app-page-builder-elements/tsconfig.build.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.build.json", "include": ["src"], "references": [ - { "path": "../lexical-editor/tsconfig.build.json" }, + { "path": "../react-composition/tsconfig.build.json" }, { "path": "../theme/tsconfig.build.json" } ], "compilerOptions": { diff --git a/packages/app-page-builder-elements/tsconfig.json b/packages/app-page-builder-elements/tsconfig.json index 33e3a51328c..14e87e244a7 100644 --- a/packages/app-page-builder-elements/tsconfig.json +++ b/packages/app-page-builder-elements/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src", "__tests__"], - "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }], + "references": [{ "path": "../react-composition" }, { "path": "../theme" }], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], "outDir": "./dist", @@ -9,8 +9,8 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/lexical-editor/*": ["../lexical-editor/src/*"], - "@webiny/lexical-editor": ["../lexical-editor/src"], + "@webiny/react-composition/*": ["../react-composition/src/*"], + "@webiny/react-composition": ["../react-composition/src"], "@webiny/theme/*": ["../theme/src/*"], "@webiny/theme": ["../theme/src"] }, diff --git a/packages/app-page-builder/package.json b/packages/app-page-builder/package.json index b77bc2de637..9299207210f 100644 --- a/packages/app-page-builder/package.json +++ b/packages/app-page-builder/package.json @@ -34,6 +34,7 @@ "@webiny/app-security": "0.0.0", "@webiny/app-tenancy": "0.0.0", "@webiny/app-theme": "0.0.0", + "@webiny/error": "0.0.0", "@webiny/form": "0.0.0", "@webiny/lexical-editor": "0.0.0", "@webiny/plugins": "0.0.0", diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index a134f7fbb00..259b3561bb7 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -11,6 +11,11 @@ import { DefaultOnPageUnpublish } from "~/admin/plugins/pageDetails/pageRevision import { DefaultOnPageDelete } from "~/admin/plugins/pageDetails/pageRevisions/DefaultOnPageDelete"; import { EditorProps, EditorRenderer } from "./admin/components/Editor"; import { PagesModule } from "~/admin/views/Pages/PagesModule"; +import { AddButtonLinkComponent } from "~/elementDecorators/AddButtonLinkComponent"; +import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandlers"; +import { InjectElementVariables } from "~/render/variables/InjectElementVariables"; +import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph"; +import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading"; export type { EditorProps }; export { EditorRenderer }; @@ -131,6 +136,12 @@ export const PageBuilder = () => { + {/* Element renderer plugins. */} + + + + + ); }; diff --git a/packages/app-page-builder/src/admin/graphql/pages.ts b/packages/app-page-builder/src/admin/graphql/pages.ts index 668bd6182ad..25b1111ea22 100644 --- a/packages/app-page-builder/src/admin/graphql/pages.ts +++ b/packages/app-page-builder/src/admin/graphql/pages.ts @@ -154,10 +154,15 @@ export const LIST_PAGES = gql` */ export interface GetPageQueryResponse { pageBuilder: { - getPage: { - data: T | null; - error: PbErrorResponse | null; - }; + getPage: + | { + data: T; + error: null; + } + | { + data: null; + error: PbErrorResponse; + }; }; } diff --git a/packages/app-page-builder/src/admin/hooks/usePage.ts b/packages/app-page-builder/src/admin/hooks/usePage.ts new file mode 100644 index 00000000000..9e119d60f4c --- /dev/null +++ b/packages/app-page-builder/src/admin/hooks/usePage.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@apollo/react-hooks"; +import { + GET_PAGE, + GetPageQueryResponse, + GetPageQueryVariables, + PageResponseData +} from "~/admin/graphql/pages"; +import { PbErrorResponse } from "~/types"; + +export type Page = PageResponseData & { settings: Record }; + +export const usePage = ( + pageId: string +): { loading: boolean; page: Page | undefined; error: PbErrorResponse | undefined } => { + const query = useQuery(GET_PAGE, { + variables: { id: String(pageId) }, + skip: !pageId + }); + + const { data, error } = query.data?.pageBuilder.getPage ?? { data: null, error: null }; + + return { loading: query.loading, page: (data as Page) || undefined, error: error || undefined }; +}; diff --git a/packages/app-page-builder/src/admin/index.ts b/packages/app-page-builder/src/admin/index.ts new file mode 100644 index 00000000000..03cfdd14766 --- /dev/null +++ b/packages/app-page-builder/src/admin/index.ts @@ -0,0 +1,7 @@ +export * from "./hooks/usePage"; +export * from "./hooks/useNavigatePage"; +export * from "./hooks/usePreviewPage"; +export * from "./hooks/usePageBuilderSettings"; +export * from "./hooks/useConfigureWebsiteUrl"; +export * from "./hooks/useSiteStatus"; +export * from "./hooks/useAdminPageBuilder"; diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/header/index.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/header/index.tsx index dbeb31f4083..545578f803b 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/header/index.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/header/index.tsx @@ -25,8 +25,8 @@ const plugins: PbPageDetailsPlugin[] = [ { name: "pb-page-details-header-edit", type: "pb-page-details-header-right", - render(props) { - return ; + render() { + return ; } }, { diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx index ed1018dadbe..555d6f5d378 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx @@ -1,4 +1,5 @@ import React, { CSSProperties } from "react"; +import { QueryResult } from "@apollo/react-common"; import { css } from "emotion"; import styled from "@emotion/styled"; import { Typography } from "@webiny/ui/Typography"; @@ -6,7 +7,6 @@ import { Select } from "@webiny/ui/Select"; import { Page } from "@webiny/app-page-builder-elements/components/Page"; import { Zoom } from "./Zoom"; import { PbPageData, PbPageTemplate } from "~/types"; -import { QueryResult } from "@apollo/react-common"; const webinyZoomStyles = css` &.mdc-select--no-label:not(.mdc-select--outlined) @@ -85,7 +85,7 @@ interface PagePreviewProps { getPageQuery?: QueryResult; } -const PagePreview = ({ page }: PagePreviewProps) => { +export const PagePreview = ({ page }: PagePreviewProps) => { return ( {({ zoom, setZoom }) => ( @@ -100,5 +100,3 @@ const PagePreview = ({ page }: PagePreviewProps) => { ); }; - -export default PagePreview; diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx index 6c80169a051..a6a595aece4 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx @@ -7,7 +7,7 @@ import { import { Tab } from "@webiny/ui/Tabs"; import styled from "@emotion/styled"; import { Elevation } from "@webiny/ui/Elevation"; -import PagePreview from "./PagePreview"; +import { PagePreview } from "./PagePreview"; import { CircularProgress } from "@webiny/ui/Progress"; const RenderBlock = styled("div")({ diff --git a/packages/app-page-builder/src/admin/views/Menus/validators.ts b/packages/app-page-builder/src/admin/views/Menus/validators.ts deleted file mode 100644 index 8c60b493efd..00000000000 --- a/packages/app-page-builder/src/admin/views/Menus/validators.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const menuUrlValidator = (value: string): boolean => { - if (value.startsWith("/") && value.endsWith("/")) { - return true; - } - - throw new Error("Menu URL must begin and end with a forward slash (`/`)"); -}; diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx index ec4851cadb5..60bbde3c309 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx @@ -16,7 +16,7 @@ import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/round/d import { GET_PAGE_TEMPLATE } from "./graphql"; import { CreatableItem } from "./PageTemplates"; -import PagePreview from "~/admin/plugins/pageDetails/previewContent/PagePreview"; +import { PagePreview } from "~/admin/plugins/pageDetails/previewContent/PagePreview"; import { PbPageTemplate } from "~/types"; const t = i18n.ns("app-page-builder/admin/views/page-templates/page-template-details"); diff --git a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx index da16f3cd3de..fd71a138bf9 100644 --- a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx @@ -15,7 +15,7 @@ import { ButtonSecondary } from "@webiny/ui/Button"; import { ReactComponent as SearchIcon } from "~/editor/assets/icons/search.svg"; import { useKeyHandler } from "~/editor/hooks/useKeyHandler"; import { LIST_PAGE_TEMPLATES } from "~/admin/views/PageTemplates/graphql"; -import PagePreview from "~/admin/plugins/pageDetails/previewContent/PagePreview"; +import { PagePreview } from "~/admin/plugins/pageDetails/previewContent/PagePreview"; import { listItem, activeListItem, diff --git a/packages/app-page-builder/src/admin/views/Pages/cache.ts b/packages/app-page-builder/src/admin/views/Pages/cache.ts index 887d3751d57..2f15b1403f5 100644 --- a/packages/app-page-builder/src/admin/views/Pages/cache.ts +++ b/packages/app-page-builder/src/admin/views/Pages/cache.ts @@ -177,10 +177,11 @@ export const removeRevisionFromEntryCache = ( ): PbPageRevision[] => { const gqlParams = { query: GQL.GET_PAGE, - variables: { id: revision.pid } + variables: { id: revision.id } }; const data = cache.readQuery(gqlParams); + const revisions = get( data, "pageBuilder.getPage.data.revisions" diff --git a/packages/app-page-builder/src/editor/components/Text.tsx b/packages/app-page-builder/src/editor/components/Text.tsx index 8d77a3c8ee5..91f6c518b22 100644 --- a/packages/app-page-builder/src/editor/components/Text.tsx +++ b/packages/app-page-builder/src/editor/components/Text.tsx @@ -1,7 +1,7 @@ import React from "react"; import { CoreOptions } from "medium-editor"; -import PeText from "./Text/PeText"; +import { PeText } from "./Text/PeText"; interface TextElementProps { elementId: string; diff --git a/packages/app-page-builder/src/editor/components/Text/PeText.tsx b/packages/app-page-builder/src/editor/components/Text/PeText.tsx index 09df33d5a20..c700d51e5dd 100644 --- a/packages/app-page-builder/src/editor/components/Text/PeText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PeText.tsx @@ -1,13 +1,12 @@ import React, { useCallback, useMemo } from "react"; -import { useRecoilState, useRecoilValue } from "recoil"; import get from "lodash/get"; import { CoreOptions } from "medium-editor"; import { makeDecoratable } from "@webiny/react-composition"; import { PbEditorElement } from "~/types"; -import { elementWithChildrenByIdSelector, activeElementAtom, uiAtom } from "../../recoil/modules"; import useUpdateHandlers from "../../plugins/elementSettings/useUpdateHandlers"; import ReactMediumEditor from "../../components/MediumEditor"; import { applyFallbackDisplayMode } from "../../plugins/elementSettings/elementSettingsUtils"; +import { useActiveElementId, useDisplayMode, useElementById } from "~/editor"; const DATA_NAMESPACE = "data.text"; @@ -17,12 +16,12 @@ interface TextElementProps { tag?: string | [string, Record]; } -const PeText = makeDecoratable( +export const PeText = makeDecoratable( "PeText", ({ elementId, mediumEditorOptions, tag: customTag }: TextElementProps) => { - const element = useRecoilValue(elementWithChildrenByIdSelector(elementId)); - const [{ displayMode }] = useRecoilState(uiAtom); - const [activeElementId, setActiveElementAtomValue] = useRecoilState(activeElementAtom); + const [element] = useElementById(elementId); + const { displayMode } = useDisplayMode(); + const [activeElementId, setActiveElementId] = useActiveElementId(); const { getUpdateValue } = useUpdateHandlers({ element: element as PbEditorElement, dataNamespace: DATA_NAMESPACE @@ -48,7 +47,7 @@ const PeText = makeDecoratable( const onSelect = useCallback(() => { // Mark element active on editor element selection if (elementId && activeElementId !== elementId) { - setActiveElementAtomValue(elementId); + setActiveElementId(elementId); } }, [activeElementId, elementId]); @@ -72,5 +71,3 @@ const PeText = makeDecoratable( ); } ); - -export default PeText; diff --git a/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx b/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx index 5fd00b12573..789ae7cc3d7 100644 --- a/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx +++ b/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx @@ -9,11 +9,16 @@ import { import { Tab } from "./Tab"; import { useActiveGroup } from "~/editor/config/Sidebar/useActiveGroup"; import { createGetId } from "~/editor/config/createGetId"; +import { CurrentBlockProvider } from "~/editor/contexts/CurrentBlockProvider"; const SCOPE = "sidebar"; const BaseSidebar = () => { - return ; + return ( + + + + ); }; export type ScopedElementProps = Omit; diff --git a/packages/app-page-builder/src/editor/contexts/CurrentBlockProvider.tsx b/packages/app-page-builder/src/editor/contexts/CurrentBlockProvider.tsx new file mode 100644 index 00000000000..ac8535f166a --- /dev/null +++ b/packages/app-page-builder/src/editor/contexts/CurrentBlockProvider.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { useRecoilValue } from "recoil"; +import { BlockProvider } from "@webiny/app-page-builder-elements/renderers/block/BlockProvider"; +import { Element } from "@webiny/app-page-builder-elements/types"; +import { blockByElementSelector } from "~/editor/hooks/useCurrentBlockElement"; +import { useActiveElementId } from "~/editor/hooks/useActiveElementId"; + +export const CurrentBlockProvider = ({ children }: { children: React.ReactNode }) => { + const [activeElementId] = useActiveElementId(); + const editorBlock = useRecoilValue(blockByElementSelector(activeElementId || undefined)); + + const block = editorBlock ? (editorBlock as Element) : null; + + return {children}; +}; diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlsOverlay.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlsOverlay.tsx index 56088cfd275..649fbad7d3f 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlsOverlay.tsx +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlsOverlay.tsx @@ -258,7 +258,7 @@ export const ElementControlsOverlay = (props: Props) => { return "block | unknown"; } - return getElementTitle(element.type); + return getElementTitle(element.type, element.id); }, [element.data.blockId]); // Z-index of element controls overlay depends on the depth of the page element. diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts index 6584e32f359..13f11566978 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts @@ -7,7 +7,7 @@ const titlesCache: Record = {}; * Returns element title from element's plugin. If plugin is not found, it will * return the element type. A simple cache was added to avoid unnecessary lookups. */ -export const getElementTitle = (elementType: string): string => { +export const getElementTitle = (elementType: string, suffix?: string): string => { if (elementType in titlesCache) { return titlesCache[elementType]; } @@ -30,5 +30,9 @@ export const getElementTitle = (elementType: string): string => { titlesCache[elementType] = elementType.charAt(0).toUpperCase() + elementType.slice(1); } + titlesCache[elementType] = suffix + ? `${titlesCache[elementType]} | ${suffix}` + : titlesCache[elementType]; + return titlesCache[elementType]; }; diff --git a/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts index 465d81e2f1b..4c628b20688 100644 --- a/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts +++ b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts @@ -4,7 +4,7 @@ import { PbEditorElement, PbEditorPageElementVariableRendererPlugin } from "~/ty import { useParentBlock } from "~/editor/hooks/useParentBlock"; export function useElementVariables(element: PbEditorElement | null) { - const block = useParentBlock(element?.id); + const block = useParentBlock() as PbEditorElement | null; const variableValue = useMemo(() => { const { variableId } = element?.data || {}; diff --git a/packages/app-page-builder/src/editor/hooks/useParentBlock.ts b/packages/app-page-builder/src/editor/hooks/useParentBlock.ts index 2a4b52933db..495e207ba4e 100644 --- a/packages/app-page-builder/src/editor/hooks/useParentBlock.ts +++ b/packages/app-page-builder/src/editor/hooks/useParentBlock.ts @@ -1,6 +1 @@ -import { useRecoilValue } from "recoil"; -import { blockByElementSelector } from "~/editor/hooks/useCurrentBlockElement"; - -export function useParentBlock(elementId?: string) { - return useRecoilValue(blockByElementSelector(elementId)); -} +export { useParentBlock } from "@webiny/app-page-builder-elements/renderers/block/BlockProvider"; diff --git a/packages/app-page-builder/src/editor/index.tsx b/packages/app-page-builder/src/editor/index.tsx index 5a6cfd536b3..795f9b3b3a1 100644 --- a/packages/app-page-builder/src/editor/index.tsx +++ b/packages/app-page-builder/src/editor/index.tsx @@ -1,4 +1,5 @@ export { DefaultEditorConfig } from "./defaultConfig/DefaultEditorConfig"; export * from "./config"; export * from "./hooks"; +export * from "./contexts/EditorProvider"; export { default as DropZone } from "../editor/components/DropZone"; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts index d0f507b6757..e57cdc387b6 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts @@ -1,12 +1,10 @@ import React, { useCallback } from "react"; -import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; -import { DeleteElementActionEvent } from "../../../recoil/actions"; -import { activeElementAtom, elementByIdSelector } from "../../../recoil/modules"; import { plugins } from "@webiny/plugins"; +import { useActiveElement, useEventActionHandler } from "~/editor"; import { PbEditorPageElementPlugin, PbBlockVariable, PbEditorElement } from "~/types"; -import { useRecoilValue } from "recoil"; import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; import { useParentBlock } from "~/editor/hooks/useParentBlock"; +import { DeleteElementActionEvent } from "~/editor/recoil/actions"; const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => { const variables = block.data.variables ?? []; @@ -29,9 +27,8 @@ interface DeleteActionPropsType { } const DeleteAction = ({ children }: DeleteActionPropsType) => { const eventActionHandler = useEventActionHandler(); - const activeElementId = useRecoilValue(activeElementAtom); - const element = useRecoilValue(elementByIdSelector(activeElementId as string)); - const block = useParentBlock(activeElementId as string); + const [element] = useActiveElement(); + const block = useParentBlock(); const updateElement = useUpdateElement(); if (!element) { @@ -50,7 +47,7 @@ const DeleteAction = ({ children }: DeleteActionPropsType) => { element }) ); - }, [activeElementId]); + }, [element.id]); const plugin = plugins .byType("pb-editor-page-element") diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/text/TextSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/text/TextSettings.tsx index 0691839a7b9..7c201f016e9 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/text/TextSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/text/TextSettings.tsx @@ -154,10 +154,6 @@ const TextSettings = ({ defaultAccordionValue, options }: TextSettingsProps) => const text = get(element, `${DATA_NAMESPACE}.${displayMode}`, fallbackValue); - if (!text) { - return null; - } - // For the new editor, we only want to show text alignment options. We check if the editor is new by // examining the text data. If it's JSON, then it's the new editor. Otherwise, it's the old editor. const textData = element.data?.text?.data?.text; @@ -174,7 +170,7 @@ const TextSettings = ({ defaultAccordionValue, options }: TextSettingsProps) => } }, [textData]); - if (usingLexicalEditor) { + if (!text || usingLexicalEditor) { return null; } diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/basic/button/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/basic/button/index.tsx index 6a30e09c020..4820107c854 100644 --- a/packages/app-page-builder/src/editor/plugins/elementVariables/basic/button/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/basic/button/index.tsx @@ -10,13 +10,17 @@ export default { getVariableValue(element) { const variables = useElementVariables(element); + if (!variables.length) { + return null; + } + return { label: - variables?.find((variable: PbBlockVariable) => + variables.find((variable: PbBlockVariable) => variable.id.endsWith(".label") )?.value || null, url: - variables?.find((variable: PbBlockVariable) => variable.id.endsWith(".url")) + variables.find((variable: PbBlockVariable) => variable.id.endsWith(".url")) ?.value || null }; }, diff --git a/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx b/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx index f242eea67d1..960c59a494a 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx @@ -1,16 +1,27 @@ import React from "react"; -import { PbEditorElement } from "~/types"; -import PeBlock from "./PeBlock"; - +import { useRecoilValue } from "recoil"; +import { BlockRenderer } from "@webiny/app-page-builder-elements/renderers/block"; import { Element } from "@webiny/app-page-builder-elements/types"; +import { elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; +import { EmptyCell } from "~/editor/plugins/elements/cell/EmptyCell"; +import { PbEditorElement } from "~/types"; -interface BlockProps { +type Props = Omit, "element"> & { element: PbEditorElement; -} - -const Block = (props: BlockProps) => { - const { element, ...rest } = props; - return ; }; -export default Block; +export const Block = (props: Props) => { + const { element } = props; + + const elementWithChildren = useRecoilValue( + elementWithChildrenByIdSelector(element.id) + ) as Element; + + const childrenElements = elementWithChildren?.elements; + + if (Array.isArray(childrenElements) && childrenElements.length > 0) { + return ; + } + + return ; +}; diff --git a/packages/app-page-builder/src/editor/plugins/elements/block/PeBlock.tsx b/packages/app-page-builder/src/editor/plugins/elements/block/PeBlock.tsx deleted file mode 100644 index 21c0fe5e3f3..00000000000 --- a/packages/app-page-builder/src/editor/plugins/elements/block/PeBlock.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react"; -import { createRenderer, useRenderer, Elements } from "@webiny/app-page-builder-elements"; -import { Element } from "@webiny/app-page-builder-elements/types"; -import { useRecoilValue } from "recoil"; -import { elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; -import EmptyCell from "~/editor/plugins/elements/cell/EmptyCell"; - -const PeBlock = createRenderer( - () => { - const { getElement } = useRenderer(); - const element = getElement(); - - const elementWithChildren = useRecoilValue( - elementWithChildrenByIdSelector(element.id) - ) as Element; - - const childrenElements = elementWithChildren?.elements; - if (Array.isArray(childrenElements) && childrenElements.length > 0) { - return ( - <> - - {element.data.blockId && ( - - )} - - ); - } - - return ; - }, - { - baseStyles: { - display: "flex", - flexDirection: "column", - justifyContent: "flex-start", - alignItems: "flex-start", - boxSizing: "border-box" - } - } -); - -export default PeBlock; diff --git a/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx index 5b18f796dd0..ef2e73a3cce 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx @@ -1,6 +1,6 @@ import React from "react"; import kebabCase from "lodash/kebabCase"; -import Block from "./Block"; +import { Block } from "./Block"; import { DisplayMode, PbEditorPageElementPlugin, diff --git a/packages/app-page-builder/src/editor/plugins/elements/button/Button.tsx b/packages/app-page-builder/src/editor/plugins/elements/button/Button.tsx deleted file mode 100644 index c5ac6bbaa59..00000000000 --- a/packages/app-page-builder/src/editor/plugins/elements/button/Button.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; -import { PbEditorElement } from "~/types"; -import PeButton from "./PeButton"; - -import { Element } from "@webiny/app-page-builder-elements/types"; - -interface ButtonProps { - element: PbEditorElement; -} - -const Button = (props: ButtonProps) => { - const { element, ...rest } = props; - return ; -}; - -export default Button; diff --git a/packages/app-page-builder/src/editor/plugins/elements/button/PeButton.tsx b/packages/app-page-builder/src/editor/plugins/elements/button/PeButton.tsx deleted file mode 100644 index fc4ef20c737..00000000000 --- a/packages/app-page-builder/src/editor/plugins/elements/button/PeButton.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { PbButtonElementClickHandlerPlugin } from "~/types"; -import { createButton } from "@webiny/app-page-builder-elements/renderers/button"; -import { plugins } from "@webiny/plugins"; -import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; -import { Element } from "@webiny/app-page-builder-elements/types"; - -const Button = createButton({ - clickHandlers: () => { - const registeredPlugins = plugins.byType( - "pb-page-element-button-click-handler" - ); - - return registeredPlugins.map(plugin => { - return { - id: plugin.name!, - name: plugin.title, - handler: plugin.handler, - variables: plugin.variables - }; - }); - } -}); - -interface Props { - element: Element; -} - -const PeButton = (props: Props) => { - const { element } = props; - const variableValue = useElementVariableValue(element); - if (variableValue) { - return ( -