diff --git a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts index 3274b7f2b6f..4ed03912cda 100644 --- a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts +++ b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts @@ -9,30 +9,27 @@ import list from "@webiny/app-page-builder/editor/plugins/elements/list"; import quote from "@webiny/app-page-builder/editor/plugins/elements/quote"; import icon from "@webiny/app-page-builder/editor/plugins/elements/icon"; import button from "@webiny/app-page-builder/editor/plugins/elements/button"; -import soundcloud from "@webiny/app-page-builder/editor/plugins/elements/media/soundcloud"; -import vimeo from "@webiny/app-page-builder/editor/plugins/elements/media/vimeo"; -import youtube from "@webiny/app-page-builder/editor/plugins/elements/media/youtube"; -import iframe from "@webiny/app-page-builder/editor/plugins/elements/media/iframe"; +import soundcloud from "@webiny/app-page-builder/editor/plugins/elements/embeds/soundcloud"; +import vimeo from "@webiny/app-page-builder/editor/plugins/elements/embeds/vimeo"; +import youtube from "@webiny/app-page-builder/editor/plugins/elements/embeds/youtube"; +import iframe from "@webiny/app-page-builder/editor/plugins/elements/embeds/iframe"; import pinterest from "@webiny/app-page-builder/editor/plugins/elements/social/pinterest"; import twitter from "@webiny/app-page-builder/editor/plugins/elements/social/twitter"; import codesandbox from "@webiny/app-page-builder/editor/plugins/elements/code/codesandbox"; import pagesList from "@webiny/app-page-builder/editor/plugins/elements/pagesList"; import imagesList from "@webiny/app-page-builder/editor/plugins/elements/imagesList"; import heading from "@webiny/app-page-builder/editor/plugins/elements/heading"; -// Icons -import icons from "@webiny/app-page-builder/editor/plugins/icons"; // Element groups import basicGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/basic"; import layoutGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/layout"; import mediaGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/media"; +import embedsGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/embeds"; import formGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/form"; import socialGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/social"; import codeGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/code"; import savedGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/saved"; // Blocks -import gridBlock from "@webiny/app-page-builder/editor/plugins/blocks/gridBlock"; -// Block categories -import blocksCategories from "@webiny/app-page-builder/editor/plugins/blocksCategories"; +import emptyBlock from "@webiny/app-page-builder/pageEditor/plugins/blocks/emptyBlock"; // Toolbar import addElement from "@webiny/app-page-builder/editor/plugins/toolbar/addElement"; import navigator from "@webiny/app-page-builder/editor/plugins/toolbar/navigator"; @@ -68,7 +65,7 @@ export default [ document(), grid(), block(), - gridBlock, + emptyBlock, cell(), heading(), paragraph(), @@ -88,18 +85,15 @@ export default [ pagesList(), // grid presets ...gridPresets, - // Icons - icons, // Element groups basicGroup, formGroup, layoutGroup, mediaGroup, + embedsGroup, socialGroup, codeGroup, savedGroup, - // Block categories - blocksCategories, // Toolbar addElement, navigator(), diff --git a/apps/admin/src/plugins/pageBuilder/renderPlugins.ts b/apps/admin/src/plugins/pageBuilder/renderPlugins.ts index 85f1fa1f0fc..f5ca887a96c 100644 --- a/apps/admin/src/plugins/pageBuilder/renderPlugins.ts +++ b/apps/admin/src/plugins/pageBuilder/renderPlugins.ts @@ -26,6 +26,7 @@ import align from "@webiny/app-page-builder/render/plugins/elementSettings/align import animation from "@webiny/app-page-builder/render/plugins/elementSettings/animation"; import background from "@webiny/app-page-builder/render/plugins/elementSettings/background"; import border from "@webiny/app-page-builder/render/plugins/elementSettings/border"; +import gridSettings from "@webiny/app-page-builder/render/plugins/elementSettings/grid"; import height from "@webiny/app-page-builder/render/plugins/elementSettings/height"; import width from "@webiny/app-page-builder/render/plugins/elementSettings/width"; import shadow from "@webiny/app-page-builder/render/plugins/elementSettings/shadow"; @@ -65,6 +66,7 @@ export default [ animation, background, border, + gridSettings, height, width, shadow, diff --git a/apps/theme/formBuilder/styles/theme.scss b/apps/theme/formBuilder/styles/theme.scss index 6726745b29b..68780e020df 100644 --- a/apps/theme/formBuilder/styles/theme.scss +++ b/apps/theme/formBuilder/styles/theme.scss @@ -11,6 +11,7 @@ display: flex; width: 100%; padding: 0; + background-color: var(--webiny-theme-color-surface, #ffffff); } .webiny-fb-form-layout-column { diff --git a/apps/theme/pageBuilder/components/Menu.tsx b/apps/theme/pageBuilder/components/Menu.tsx index 548a754391a..1f5f5b7dd8e 100644 --- a/apps/theme/pageBuilder/components/Menu.tsx +++ b/apps/theme/pageBuilder/components/Menu.tsx @@ -19,7 +19,7 @@ declare global { export const hasMenuItems = (data: GetPublishMenuQueryResponse): boolean => { return Boolean( data && - Array.isArray(data.pageBuilder.getPublicMenu.data.items) && + Array.isArray(data.pageBuilder.getPublicMenu.data?.items) && data.pageBuilder.getPublicMenu.data.items.length ); }; diff --git a/apps/theme/pageBuilder/styles/base.scss b/apps/theme/pageBuilder/styles/base.scss index b7dfab79564..600f2e6c0be 100644 --- a/apps/theme/pageBuilder/styles/base.scss +++ b/apps/theme/pageBuilder/styles/base.scss @@ -74,6 +74,8 @@ justify-content: var(--desktop-justify-content); align-items: var(--desktop-align-items); + + box-shadow: var(--box-shadow); } // Some styles need more specificity .webiny-pb-media-query--desktop .webiny-pb-base-page-element-style { diff --git a/apps/theme/pageBuilder/styles/elements/image.scss b/apps/theme/pageBuilder/styles/elements/image.scss index f2b28db2928..316dc5cf300 100644 --- a/apps/theme/pageBuilder/styles/elements/image.scss +++ b/apps/theme/pageBuilder/styles/elements/image.scss @@ -9,11 +9,12 @@ img { } .webiny-pb-page-element-image { - overflow: hidden; box-sizing: border-box; + box-shadow: none !important; //display: flex; removed because of testimonial #2 img { max-width: 100%; + box-shadow: var(--box-shadow); } } // make images center align on mobile diff --git a/apps/theme/pageBuilder/styles/elements/layout.scss b/apps/theme/pageBuilder/styles/elements/layout.scss index b6cd79c2a0c..01e5a170a69 100644 --- a/apps/theme/pageBuilder/styles/elements/layout.scss +++ b/apps/theme/pageBuilder/styles/elements/layout.scss @@ -27,72 +27,49 @@ .webiny-pb-layout-grid { width: 100%; display: flex; - flex-direction: row; +} - &-cell { +.webiny-pb-media-query--desktop .webiny-pb-layout-grid { + flex-direction: var(--desktop-flex-direction, "row"); + &-cell { @for $current from 1 to 13 { &-#{$current} { - width: math.percentage(math.div(1, 12) * $current); + width: var(--desktop-cell-width, math.percentage(math.div(1, 12) * $current)); } } - - & img { - width: auto; - height: auto; - } } } - -.webiny-pb-media-query--desktop .webiny-pb-layout-grid { - flex-direction: row; - // flex: 100% 1; // removed because Content #2 -} .webiny-pb-media-query--tablet .webiny-pb-layout-grid { - flex-direction: row; - // flex: 100% 1; // removed because Content #2 -} -.webiny-pb-media-query--mobile-landscape .webiny-pb-layout-grid { - flex-direction: column; - max-width: 100% !important; - //width: 100% !important; // added for Content #6 - //width: auto !important; // set to auto for CTA #9 - flex: 0 auto !important; + flex-direction: var(--tablet-flex-direction, "row"); &-cell { - flex-grow: 1; - @for $current from 1 to 13 { &-#{$current} { - width: 100%; + width: var(--tablet-cell-width, math.percentage(math.div(1, 12) * $current)); } } + } +} +.webiny-pb-media-query--mobile-landscape .webiny-pb-layout-grid { + flex-direction: var(--mobile-landscape-flex-direction, "column"); - & img { - width: auto; - height: auto; + &-cell { + @for $current from 1 to 13 { + &-#{$current} { + width: var(--mobile-landscape-cell-width, math.percentage(math.div(1, 12) * $current)); + } } } } .webiny-pb-media-query--mobile-portrait .webiny-pb-layout-grid { - flex-direction: column; - max-width: 100% !important; - //width: 100% !important; // added for Content #6 - //width: auto !important; // set to auto for CTA #9 - flex: 0 auto !important; + flex-direction: var(--mobile-portrait-flex-direction, "column"); &-cell { - flex-grow: 1; - @for $current from 1 to 13 { &-#{$current} { - width: 100%; + width: var(--mobile-portrait-cell-width, math.percentage(math.div(1, 12) * $current)); } } - - & img { - width: auto; - height: auto; - } } } diff --git a/apps/website/src/plugins/pageBuilder.ts b/apps/website/src/plugins/pageBuilder.ts index bd4a7fdb1dc..4c84eeca438 100644 --- a/apps/website/src/plugins/pageBuilder.ts +++ b/apps/website/src/plugins/pageBuilder.ts @@ -44,6 +44,7 @@ import align from "@webiny/app-page-builder/render/plugins/elementSettings/align import animation from "@webiny/app-page-builder/render/plugins/elementSettings/animation"; import background from "@webiny/app-page-builder/render/plugins/elementSettings/background"; import border from "@webiny/app-page-builder/render/plugins/elementSettings/border"; +import gridSettings from "@webiny/app-page-builder/render/plugins/elementSettings/grid"; import height from "@webiny/app-page-builder/render/plugins/elementSettings/height"; import width from "@webiny/app-page-builder/render/plugins/elementSettings/width"; import shadow from "@webiny/app-page-builder/render/plugins/elementSettings/shadow"; @@ -91,6 +92,7 @@ export default [ animation, background, border, + gridSettings, height, width, shadow, diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts new file mode 100644 index 00000000000..e1a903423ac --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts @@ -0,0 +1,52 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createBlockCategoryEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + name: { + type: "string" + }, + slug: { + type: "string" + }, + icon: { + type: "string" + }, + description: { + type: "string" + }, + createdOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts new file mode 100644 index 00000000000..30c49fbfa56 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts @@ -0,0 +1,55 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createPageBlockEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + id: { + type: "string" + }, + name: { + type: "string" + }, + blockCategory: { + type: "string" + }, + content: { + type: "map" + }, + preview: { + type: "map" + }, + createdOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-page-builder-so-ddb-es/src/index.ts b/packages/api-page-builder-so-ddb-es/src/index.ts index 98dd12f6a8e..25347685322 100644 --- a/packages/api-page-builder-so-ddb-es/src/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/index.ts @@ -1,31 +1,46 @@ import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters"; -import { createSystemStorageOperations } from "~/operations/system"; +import { PluginsContainer } from "@webiny/plugins"; +import { getElasticsearchOperators } from "@webiny/api-elasticsearch/operators"; + import { ENTITIES, StorageOperationsFactory } from "~/types"; import { createTable } from "~/definitions/table"; import { createElasticsearchTable } from "~/definitions/tableElasticsearch"; -import { createSettingsEntity } from "~/definitions/settingsEntity"; -import { createSystemEntity } from "./definitions/systemEntity"; -import { createCategoryEntity } from "~/definitions/categoryEntity"; -import { createMenuEntity } from "~/definitions/menuEntity"; -import { createPageElementEntity } from "~/definitions/pageElementEntity"; -import { createPageEntity } from "~/definitions/pageEntity"; -import { createPageElasticsearchEntity } from "~/definitions/pageElasticsearchEntity"; -import { PluginsContainer } from "@webiny/plugins"; -import { getElasticsearchOperators } from "@webiny/api-elasticsearch/operators"; +import { elasticsearchIndexPlugins } from "~/elasticsearch/indices"; import { createElasticsearchIndex } from "~/elasticsearch/createElasticsearchIndex"; -import { createSettingsStorageOperations } from "~/operations/settings"; + +import { createCategoryEntity } from "~/definitions/categoryEntity"; import { createCategoryDynamoDbFields } from "~/operations/category/fields"; import { createCategoryStorageOperations } from "~/operations/category"; + +import { createMenuEntity } from "~/definitions/menuEntity"; import { createMenuDynamoDbFields } from "~/operations/menu/fields"; import { createMenuStorageOperations } from "~/operations/menu"; + +import { createPageElementEntity } from "~/definitions/pageElementEntity"; import { createPageElementDynamoDbFields } from "~/operations/pageElement/fields"; import { createPageElementStorageOperations } from "~/operations/pageElement"; + +import { createSettingsEntity } from "~/definitions/settingsEntity"; +import { createSettingsStorageOperations } from "~/operations/settings"; + +import { createSystemEntity } from "~/definitions/systemEntity"; +import { createSystemStorageOperations } from "~/operations/system"; + +import { createPageEntity } from "~/definitions/pageEntity"; import { createPagesElasticsearchFields, createPagesDynamoDbFields } from "~/operations/pages/fields"; import { createPageStorageOperations } from "~/operations/pages"; -import { elasticsearchIndexPlugins } from "~/elasticsearch/indices"; +import { createPageElasticsearchEntity } from "~/definitions/pageElasticsearchEntity"; + +import { createBlockCategoryEntity } from "~/definitions/blockCategoryEntity"; +import { createBlockCategoryDynamoDbFields } from "~/operations/blockCategory/fields"; +import { createBlockCategoryStorageOperations } from "~/operations/blockCategory"; + +import { createPageBlockEntity } from "~/definitions/pageBlockEntity"; +import { createPageBlockDynamoDbFields } from "~/operations/pageBlock/fields"; +import { createPageBlockStorageOperations } from "~/operations/pageBlock"; export const createStorageOperations: StorageOperationsFactory = params => { const { @@ -82,7 +97,15 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Built-in Elasticsearch index templates */ - elasticsearchIndexPlugins() + elasticsearchIndexPlugins(), + /** + * Block Category fields required for filtering/sorting. + */ + createBlockCategoryDynamoDbFields(), + /** + * Page Block fields required for filtering/sorting. + */ + createPageBlockDynamoDbFields() ]); const entities = { @@ -120,6 +143,16 @@ export const createStorageOperations: StorageOperationsFactory = params => { entityName: ENTITIES.PAGES_ES, table: tableElasticsearchInstance, attributes: attributes ? attributes[ENTITIES.PAGES_ES] : {} + }), + blockCategories: createBlockCategoryEntity({ + entityName: ENTITIES.BLOCK_CATEGORIES, + table: tableInstance, + attributes: attributes ? attributes[ENTITIES.BLOCK_CATEGORIES] : {} + }), + pageBlocks: createPageBlockEntity({ + entityName: ENTITIES.PAGE_BLOCKS, + table: tableInstance, + attributes: attributes ? attributes[ENTITIES.PAGE_BLOCKS] : {} }) }; @@ -160,6 +193,14 @@ export const createStorageOperations: StorageOperationsFactory = params => { esEntity: entities.pagesEs, elasticsearch, plugins + }), + blockCategories: createBlockCategoryStorageOperations({ + entity: entities.blockCategories, + plugins + }), + pageBlocks: createPageBlockStorageOperations({ + entity: entities.pageBlocks, + plugins }) }; }; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts new file mode 100644 index 00000000000..4096d6814be --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts @@ -0,0 +1,74 @@ +import DataLoader from "dataloader"; +import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { BlockCategory } from "@webiny/api-page-builder/types"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { Entity } from "dynamodb-toolbox"; +import { createPartitionKey, createSortKey } from "./keys"; + +interface Params { + entity: Entity; +} + +type DataLoaderGetItem = Pick; + +export class BlockCategoryDataLoader { + private _getDataLoader: DataLoader | undefined = undefined; + + private readonly entity: Entity; + + constructor(params: Params) { + this.entity = params.entity; + } + + public async getOne(item: DataLoaderGetItem): Promise { + return await this.getDataLoader().load(item); + } + + public async getAll(items: DataLoaderGetItem[]): Promise { + return await this.getDataLoader().loadMany(items); + } + + public clear(): void { + this.getDataLoader().clearAll(); + } + + private getDataLoader(): DataLoader { + if (!this._getDataLoader) { + const cacheKeyFn = (key: DataLoaderGetItem) => { + return `T#${key.tenant}#L#${key.locale}#${key.slug}`; + }; + this._getDataLoader = new DataLoader( + async items => { + const batched = items.map(item => { + return this.entity.getBatch({ + PK: createPartitionKey(item), + SK: createSortKey(item) + }); + }); + + const records = await batchReadAll({ + table: this.entity.table, + items: batched + }); + + const results = records.reduce((collection, result) => { + if (!result) { + return collection; + } + const key = cacheKeyFn(result); + collection[key] = cleanupItem(this.entity, result) as BlockCategory; + return collection; + }, {} as Record); + return items.map(item => { + const key = cacheKeyFn(item); + return results[key] || null; + }); + }, + { + cacheKeyFn + } + ); + } + return this._getDataLoader; + } +} diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/fields.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/fields.ts new file mode 100644 index 00000000000..c8f78755827 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/fields.ts @@ -0,0 +1,25 @@ +import { BlockCategoryDynamoDbElasticFieldPlugin } from "~/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin"; + +export const createBlockCategoryDynamoDbFields = (): BlockCategoryDynamoDbElasticFieldPlugin[] => { + return [ + new BlockCategoryDynamoDbElasticFieldPlugin({ + field: "id" + }), + new BlockCategoryDynamoDbElasticFieldPlugin({ + field: "createdOn", + type: "date" + }), + new BlockCategoryDynamoDbElasticFieldPlugin({ + field: "savedOn", + type: "date" + }), + new BlockCategoryDynamoDbElasticFieldPlugin({ + field: "publishedOn", + type: "date" + }), + new BlockCategoryDynamoDbElasticFieldPlugin({ + field: "createdBy", + path: "createdBy.id" + }) + ]; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/index.ts new file mode 100644 index 00000000000..cc8e4bb4605 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/index.ts @@ -0,0 +1,214 @@ +import WebinyError from "@webiny/error"; +import { + BlockCategory, + BlockCategoryStorageOperations, + BlockCategoryStorageOperationsCreateParams, + BlockCategoryStorageOperationsDeleteParams, + BlockCategoryStorageOperationsGetParams, + BlockCategoryStorageOperationsListParams, + BlockCategoryStorageOperationsUpdateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { filterItems } from "@webiny/db-dynamodb/utils/filter"; +import { BlockCategoryDataLoader } from "./dataLoader"; +import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse"; +import { BlockCategoryDynamoDbElasticFieldPlugin } from "~/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { createPartitionKey, createSortKey } from "~/operations/blockCategory/keys"; + +const createType = (): string => { + return "pb.blockCategory"; +}; + +export interface CreateBlockCategoryStorageOperationsParams { + entity: Entity; + plugins: PluginsContainer; +} +export const createBlockCategoryStorageOperations = ({ + entity, + plugins +}: CreateBlockCategoryStorageOperationsParams): BlockCategoryStorageOperations => { + const dataLoader = new BlockCategoryDataLoader({ + entity + }); + + const get = async (params: BlockCategoryStorageOperationsGetParams) => { + const { where } = params; + + try { + return await dataLoader.getOne(where); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load block category by given parameters.", + ex.code || "BLOCK_CATEGORY_GET_ERROR", + { + where + } + ); + } + }; + + const create = async (params: BlockCategoryStorageOperationsCreateParams) => { + const { blockCategory } = params; + + const keys = { + PK: createPartitionKey({ + tenant: blockCategory.tenant, + locale: blockCategory.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.put({ + ...blockCategory, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create block category.", + ex.code || "BLOCK_CATEGORY_CREATE_ERROR", + { + keys + } + ); + } + }; + + const update = async (params: BlockCategoryStorageOperationsUpdateParams) => { + const { original, blockCategory } = params; + const keys = { + PK: createPartitionKey({ + tenant: original.tenant, + locale: original.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.put({ + ...blockCategory, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update block category.", + ex.code || "BLOCK_CATEGORY_UPDATE_ERROR", + { + keys, + original, + category: blockCategory + } + ); + } + }; + + const deleteBlockCategory = async (params: BlockCategoryStorageOperationsDeleteParams) => { + const { blockCategory } = params; + const keys = { + PK: createPartitionKey({ + tenant: blockCategory.tenant, + locale: blockCategory.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.delete({ + ...blockCategory, + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete block category.", + ex.code || "BLOCK_CATEGORY_DELETE_ERROR", + { + keys, + blockCategory + } + ); + } + }; + + const list = async (params: BlockCategoryStorageOperationsListParams) => { + const { where, sort, limit } = params; + + const { tenant, locale, ...restWhere } = where; + const queryAllParams: QueryAllParams = { + entity, + partitionKey: createPartitionKey({ tenant, locale }), + options: { + gt: " " + } + }; + + let items: BlockCategory[] = []; + + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list block categories by given parameters.", + ex.code || "BLOCK_CATEGORIES_LIST_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + + const fields = plugins.byType( + BlockCategoryDynamoDbElasticFieldPlugin.type + ); + + const filteredItems = filterItems({ + plugins, + where: restWhere, + items, + fields + }); + + const sortedItems = sortItems({ + items: filteredItems, + sort, + fields + }); + + return createListResponse({ + items: sortedItems, + limit: limit || 100000, + totalCount: filteredItems.length, + after: null + }); + }; + + return { + get, + create, + update, + delete: deleteBlockCategory, + list + }; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/keys.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/keys.ts new file mode 100644 index 00000000000..96846a88233 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/keys.ts @@ -0,0 +1,16 @@ +export interface PartitionKeyParams { + tenant: string; + locale: string; +} +export const createPartitionKey = (params: PartitionKeyParams): string => { + const { tenant, locale } = params; + return `T#${tenant}#L#${locale}#PB#BC`; +}; + +export interface SortKeyParams { + slug: string; +} +export const createSortKey = (params: SortKeyParams): string => { + const { slug } = params; + return slug; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts new file mode 100644 index 00000000000..a053857cd26 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts @@ -0,0 +1,74 @@ +import DataLoader from "dataloader"; +import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { PageBlock } from "@webiny/api-page-builder/types"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { Entity } from "dynamodb-toolbox"; +import { createPartitionKey, createSortKey } from "./keys"; + +interface Params { + entity: Entity; +} + +type DataLoaderGetItem = Pick; + +export class PageBlockDataLoader { + private _getDataLoader: DataLoader | undefined = undefined; + + private readonly entity: Entity; + + constructor(params: Params) { + this.entity = params.entity; + } + + public async getOne(item: DataLoaderGetItem): Promise { + return await this.getDataLoader().load(item); + } + + public async getAll(items: DataLoaderGetItem[]): Promise { + return await this.getDataLoader().loadMany(items); + } + + public clear(): void { + this.getDataLoader().clearAll(); + } + + private getDataLoader() { + if (!this._getDataLoader) { + const cacheKeyFn = (key: DataLoaderGetItem) => { + return `T#${key.tenant}#L#${key.locale}#${key.id}`; + }; + this._getDataLoader = new DataLoader( + async items => { + const batched = items.map(item => { + return this.entity.getBatch({ + PK: createPartitionKey(item), + SK: createSortKey(item) + }); + }); + + const records = await batchReadAll({ + table: this.entity.table, + items: batched + }); + + const results = records.reduce((collection, result) => { + if (!result) { + return collection; + } + const key = cacheKeyFn(result); + collection[key] = cleanupItem(this.entity, result) as PageBlock; + return collection; + }, {} as Record); + return items.map(item => { + const key = cacheKeyFn(item); + return results[key] || null; + }); + }, + { + cacheKeyFn + } + ); + } + return this._getDataLoader; + } +} diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/fields.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/fields.ts new file mode 100644 index 00000000000..3a50a1426d3 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/fields.ts @@ -0,0 +1,21 @@ +import { PageBlockDynamoDbFieldPlugin } from "~/plugins/definitions/PageBlockDynamoDbFieldPlugin"; + +export const createPageBlockDynamoDbFields = (): PageBlockDynamoDbFieldPlugin[] => { + return [ + new PageBlockDynamoDbFieldPlugin({ + field: "id" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "createdOn", + type: "date" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "savedOn", + type: "date" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "createdBy", + path: "createdBy.id" + }) + ]; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/index.ts new file mode 100644 index 00000000000..de3a7472d37 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/index.ts @@ -0,0 +1,214 @@ +import WebinyError from "@webiny/error"; +import { + PageBlock, + PageBlockStorageOperations, + PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsDeleteParams, + PageBlockStorageOperationsGetParams, + PageBlockStorageOperationsListParams, + PageBlockStorageOperationsUpdateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { filterItems } from "@webiny/db-dynamodb/utils/filter"; +import { PageBlockDataLoader } from "./dataLoader"; +import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse"; +import { PageBlockDynamoDbFieldPlugin } from "~/plugins/definitions/PageBlockDynamoDbFieldPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { createPartitionKey, createSortKey } from "./keys"; + +const createType = (): string => { + return "pb.pageBlock"; +}; + +export interface CreatePageBlockStorageOperationsParams { + entity: Entity; + plugins: PluginsContainer; +} +export const createPageBlockStorageOperations = ({ + entity, + plugins +}: CreatePageBlockStorageOperationsParams): PageBlockStorageOperations => { + const dataLoader = new PageBlockDataLoader({ + entity + }); + + const get = async (params: PageBlockStorageOperationsGetParams) => { + const { where } = params; + + try { + return await dataLoader.getOne(where); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load page block by given parameters.", + ex.code || "PAGE_BLOCK_GET_ERROR", + { + where + } + ); + } + }; + + const list = async (params: PageBlockStorageOperationsListParams) => { + const { where, sort, limit } = params; + + const { tenant, locale, ...restWhere } = where; + const queryAllParams: QueryAllParams = { + entity, + partitionKey: createPartitionKey({ tenant, locale }), + options: { + gt: " " + } + }; + + let items: PageBlock[] = []; + + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list page blocks by given parameters.", + ex.code || "PAGE_BLOCK_LIST_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + + const fields = plugins.byType( + PageBlockDynamoDbFieldPlugin.type + ); + + const filteredItems = filterItems({ + plugins, + where: restWhere, + items, + fields + }); + + const sortedItems = sortItems({ + items: filteredItems, + sort, + fields + }); + + return createListResponse({ + items: sortedItems, + limit: limit || 100000, + totalCount: filteredItems.length, + after: null + }); + }; + + const create = async (params: PageBlockStorageOperationsCreateParams) => { + const { pageBlock } = params; + + const keys = { + PK: createPartitionKey({ + tenant: pageBlock.tenant, + locale: pageBlock.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.put({ + ...pageBlock, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create page block.", + ex.code || "PAGE_BLOCK_CREATE_ERROR", + { + keys + } + ); + } + }; + + const update = async (params: PageBlockStorageOperationsUpdateParams) => { + const { original, pageBlock } = params; + const keys = { + PK: createPartitionKey({ + tenant: original.tenant, + locale: original.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.put({ + ...pageBlock, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update page block.", + ex.code || "PAGE_BLOCK_UPDATE_ERROR", + { + keys, + original, + pageBlock + } + ); + } + }; + + const deletePageBlock = async (params: PageBlockStorageOperationsDeleteParams) => { + const { pageBlock } = params; + const keys = { + PK: createPartitionKey({ + tenant: pageBlock.tenant, + locale: pageBlock.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.delete({ + ...pageBlock, + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete page block.", + ex.code || "PAGE_BLOCK_DELETE_ERROR", + { + keys, + pageBlock + } + ); + } + }; + + return { + get, + list, + create, + update, + delete: deletePageBlock + }; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/keys.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/keys.ts new file mode 100644 index 00000000000..a8e8431287c --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/keys.ts @@ -0,0 +1,16 @@ +export interface PartitionKeyParams { + tenant: string; + locale: string; +} +export const createPartitionKey = (params: PartitionKeyParams): string => { + const { tenant, locale } = params; + return `T#${tenant}#L#${locale}#PB#B`; +}; + +export interface SortKeyParams { + id: string; +} +export const createSortKey = (params: SortKeyParams): string => { + const { id } = params; + return id; +}; diff --git a/packages/api-page-builder-so-ddb-es/src/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin.ts b/packages/api-page-builder-so-ddb-es/src/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin.ts new file mode 100644 index 00000000000..4331009c023 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin.ts @@ -0,0 +1,5 @@ +import { FieldPlugin } from "@webiny/db-dynamodb/plugins/definitions/FieldPlugin"; + +export class BlockCategoryDynamoDbElasticFieldPlugin extends FieldPlugin { + public static override readonly type: string = "pageBuilder.dynamodb.es.field.blockCategory"; +} diff --git a/packages/api-page-builder-so-ddb-es/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts b/packages/api-page-builder-so-ddb-es/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts new file mode 100644 index 00000000000..df5015ade89 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts @@ -0,0 +1,5 @@ +import { FieldPlugin } from "@webiny/db-dynamodb/plugins/definitions/FieldPlugin"; + +export class PageBlockDynamoDbFieldPlugin extends FieldPlugin { + public static override readonly type: string = "pageBuilder.dynamodb.field.pageBlock"; +} diff --git a/packages/api-page-builder-so-ddb-es/src/types.ts b/packages/api-page-builder-so-ddb-es/src/types.ts index d27da1c982d..ef2341cc25b 100644 --- a/packages/api-page-builder-so-ddb-es/src/types.ts +++ b/packages/api-page-builder-so-ddb-es/src/types.ts @@ -20,7 +20,9 @@ export enum ENTITIES { MENUS = "PbMenus", PAGE_ELEMENTS = "PbPageElements", PAGES = "PbPages", - PAGES_ES = "PbPagesEs" + PAGES_ES = "PbPagesEs", + BLOCK_CATEGORIES = "PbBlockCategories", + PAGE_BLOCKS = "PbPageBlocks" } export interface TableModifier { @@ -31,7 +33,15 @@ export interface PageBuilderStorageOperations extends BasePageBuilderStorageOper getTable: () => Table; getEsTable: () => Table; getEntities: () => Record< - "system" | "settings" | "categories" | "menus" | "pageElements" | "pages" | "pagesEs", + | "system" + | "settings" + | "categories" + | "menus" + | "pageElements" + | "pages" + | "pagesEs" + | "blockCategories" + | "pageBlocks", Entity >; } diff --git a/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts b/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts new file mode 100644 index 00000000000..e1a903423ac --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts @@ -0,0 +1,52 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createBlockCategoryEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + name: { + type: "string" + }, + slug: { + type: "string" + }, + icon: { + type: "string" + }, + description: { + type: "string" + }, + createdOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts b/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts new file mode 100644 index 00000000000..30c49fbfa56 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts @@ -0,0 +1,55 @@ +import { Entity, Table } from "dynamodb-toolbox"; +import { Attributes } from "~/types"; + +interface Params { + table: Table; + entityName: string; + attributes: Attributes; +} + +export const createPageBlockEntity = (params: Params): Entity => { + const { entityName, attributes, table } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + partitionKey: true + }, + SK: { + sortKey: true + }, + TYPE: { + type: "string" + }, + id: { + type: "string" + }, + name: { + type: "string" + }, + blockCategory: { + type: "string" + }, + content: { + type: "map" + }, + preview: { + type: "map" + }, + createdOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + ...(attributes || {}) + } + }); +}; diff --git a/packages/api-page-builder-so-ddb/src/index.ts b/packages/api-page-builder-so-ddb/src/index.ts index 16b67cac139..1b55b972433 100644 --- a/packages/api-page-builder-so-ddb/src/index.ts +++ b/packages/api-page-builder-so-ddb/src/index.ts @@ -1,24 +1,39 @@ import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters"; +import { PluginsContainer } from "@webiny/plugins"; + import { ENTITIES, StorageOperationsFactory } from "~/types"; import { createTable } from "~/definitions/table"; -import { PluginsContainer } from "@webiny/plugins"; + +import { createCategoryEntity } from "~/definitions/categoryEntity"; import { createCategoryDynamoDbFields } from "~/operations/category/fields"; +import { createCategoryStorageOperations } from "~/operations/category"; + +import { createMenuEntity } from "~/definitions/menuEntity"; import { createMenuDynamoDbFields } from "~/operations/menu/fields"; +import { createMenuStorageOperations } from "~/operations/menu"; + +import { createPageElementEntity } from "~/definitions/pageElementEntity"; import { createPageElementDynamoDbFields } from "~/operations/pageElement/fields"; +import { createPageElementStorageOperations } from "~/operations/pageElement"; + import { createSettingsEntity } from "~/definitions/settingsEntity"; +import { createSettingsStorageOperations } from "~/operations/settings"; + import { createSystemEntity } from "~/definitions/systemEntity"; -import { createCategoryEntity } from "~/definitions/categoryEntity"; -import { createMenuEntity } from "~/definitions/menuEntity"; -import { createPageElementEntity } from "~/definitions/pageElementEntity"; -import { createPageEntity } from "~/definitions/pageEntity"; import { createSystemStorageOperations } from "~/operations/system"; -import { createSettingsStorageOperations } from "~/operations/settings"; -import { createCategoryStorageOperations } from "~/operations/category"; -import { createMenuStorageOperations } from "~/operations/menu"; -import { createPageElementStorageOperations } from "~/operations/pageElement"; + +import { createPageEntity } from "~/definitions/pageEntity"; import { createPageFields } from "~/operations/pages/fields"; import { createPageStorageOperations } from "~/operations/pages"; +import { createBlockCategoryEntity } from "~/definitions/blockCategoryEntity"; +import { createBlockCategoryDynamoDbFields } from "~/operations/blockCategory/fields"; +import { createBlockCategoryStorageOperations } from "~/operations/blockCategory"; + +import { createPageBlockEntity } from "~/definitions/pageBlockEntity"; +import { createPageBlockDynamoDbFields } from "~/operations/pageBlock/fields"; +import { createPageBlockStorageOperations } from "~/operations/pageBlock"; + export const createStorageOperations: StorageOperationsFactory = params => { const { documentClient, table, attributes, plugins: userPlugins } = params; @@ -51,7 +66,15 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Page fields required for filtering/sorting. */ - createPageFields() + createPageFields(), + /** + * Block Category fields required for filtering/sorting. + */ + createBlockCategoryDynamoDbFields(), + /** + * Page Block fields required for filtering/sorting. + */ + createPageBlockDynamoDbFields() ]); const entities = { @@ -84,6 +107,16 @@ export const createStorageOperations: StorageOperationsFactory = params => { entityName: ENTITIES.PAGES, table: tableInstance, attributes: attributes ? attributes[ENTITIES.PAGES] : {} + }), + blockCategories: createBlockCategoryEntity({ + entityName: ENTITIES.BLOCK_CATEGORIES, + table: tableInstance, + attributes: attributes ? attributes[ENTITIES.BLOCK_CATEGORIES] : {} + }), + pageBlocks: createPageBlockEntity({ + entityName: ENTITIES.PAGE_BLOCKS, + table: tableInstance, + attributes: attributes ? attributes[ENTITIES.PAGE_BLOCKS] : {} }) }; @@ -111,6 +144,14 @@ export const createStorageOperations: StorageOperationsFactory = params => { pages: createPageStorageOperations({ entity: entities.pages, plugins + }), + blockCategories: createBlockCategoryStorageOperations({ + entity: entities.blockCategories, + plugins + }), + pageBlocks: createPageBlockStorageOperations({ + entity: entities.pageBlocks, + plugins }) }; }; diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts new file mode 100644 index 00000000000..626c8caaecd --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts @@ -0,0 +1,74 @@ +import DataLoader from "dataloader"; +import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { BlockCategory } from "@webiny/api-page-builder/types"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { Entity } from "dynamodb-toolbox"; +import { createPartitionKey, createSortKey } from "./keys"; + +interface Params { + entity: Entity; +} + +type DataLoaderGetItem = Pick; + +export class BlockCategoryDataLoader { + private _getDataLoader: DataLoader | undefined = undefined; + + private readonly entity: Entity; + + constructor(params: Params) { + this.entity = params.entity; + } + + public async getOne(item: DataLoaderGetItem): Promise { + return await this.getDataLoader().load(item); + } + + public async getAll(items: DataLoaderGetItem[]): Promise { + return await this.getDataLoader().loadMany(items); + } + + public clear(): void { + this.getDataLoader().clearAll(); + } + + private getDataLoader() { + if (!this._getDataLoader) { + const cacheKeyFn = (key: DataLoaderGetItem) => { + return `T#${key.tenant}#L#${key.locale}#${key.slug}`; + }; + this._getDataLoader = new DataLoader( + async items => { + const batched = items.map(item => { + return this.entity.getBatch({ + PK: createPartitionKey(item), + SK: createSortKey(item) + }); + }); + + const records = await batchReadAll({ + table: this.entity.table, + items: batched + }); + + const results = records.reduce((collection, result) => { + if (!result) { + return collection; + } + const key = cacheKeyFn(result); + collection[key] = cleanupItem(this.entity, result) as BlockCategory; + return collection; + }, {} as Record); + return items.map(item => { + const key = cacheKeyFn(item); + return results[key] || null; + }); + }, + { + cacheKeyFn + } + ); + } + return this._getDataLoader; + } +} diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/fields.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/fields.ts new file mode 100644 index 00000000000..994c89ad057 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/fields.ts @@ -0,0 +1,25 @@ +import { BlockCategoryDynamoDbFieldPlugin } from "~/plugins/definitions/BlockCategoryDynamoDbFieldPlugin"; + +export const createBlockCategoryDynamoDbFields = (): BlockCategoryDynamoDbFieldPlugin[] => { + return [ + new BlockCategoryDynamoDbFieldPlugin({ + field: "id" + }), + new BlockCategoryDynamoDbFieldPlugin({ + field: "createdOn", + type: "date" + }), + new BlockCategoryDynamoDbFieldPlugin({ + field: "savedOn", + type: "date" + }), + new BlockCategoryDynamoDbFieldPlugin({ + field: "publishedOn", + type: "date" + }), + new BlockCategoryDynamoDbFieldPlugin({ + field: "createdBy", + path: "createdBy.id" + }) + ]; +}; diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/index.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/index.ts new file mode 100644 index 00000000000..24cbb0481d1 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/index.ts @@ -0,0 +1,214 @@ +import WebinyError from "@webiny/error"; +import { + BlockCategory, + BlockCategoryStorageOperations, + BlockCategoryStorageOperationsCreateParams, + BlockCategoryStorageOperationsDeleteParams, + BlockCategoryStorageOperationsGetParams, + BlockCategoryStorageOperationsListParams, + BlockCategoryStorageOperationsUpdateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { filterItems } from "@webiny/db-dynamodb/utils/filter"; +import { BlockCategoryDataLoader } from "./dataLoader"; +import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse"; +import { BlockCategoryDynamoDbFieldPlugin } from "~/plugins/definitions/BlockCategoryDynamoDbFieldPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { createPartitionKey, createSortKey } from "~/operations/blockCategory/keys"; + +const createType = (): string => { + return "pb.blockCategory"; +}; + +export interface CreateBlockCategoryStorageOperationsParams { + entity: Entity; + plugins: PluginsContainer; +} +export const createBlockCategoryStorageOperations = ({ + entity, + plugins +}: CreateBlockCategoryStorageOperationsParams): BlockCategoryStorageOperations => { + const dataLoader = new BlockCategoryDataLoader({ + entity + }); + + const get = async (params: BlockCategoryStorageOperationsGetParams) => { + const { where } = params; + + try { + return await dataLoader.getOne(where); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load block category by given parameters.", + ex.code || "BLOCK_CATEGORY_GET_ERROR", + { + where + } + ); + } + }; + + const create = async (params: BlockCategoryStorageOperationsCreateParams) => { + const { blockCategory } = params; + + const keys = { + PK: createPartitionKey({ + tenant: blockCategory.tenant, + locale: blockCategory.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.put({ + ...blockCategory, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create block category.", + ex.code || "BLOCK_CATEGORY_CREATE_ERROR", + { + keys + } + ); + } + }; + + const update = async (params: BlockCategoryStorageOperationsUpdateParams) => { + const { original, blockCategory } = params; + const keys = { + PK: createPartitionKey({ + tenant: original.tenant, + locale: original.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.put({ + ...blockCategory, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update block category.", + ex.code || "BLOCK_CATEGORY_UPDATE_ERROR", + { + keys, + original, + blockCategory + } + ); + } + }; + + const deleteBlockCategory = async (params: BlockCategoryStorageOperationsDeleteParams) => { + const { blockCategory } = params; + const keys = { + PK: createPartitionKey({ + tenant: blockCategory.tenant, + locale: blockCategory.locale + }), + SK: createSortKey(blockCategory) + }; + + try { + await entity.delete({ + ...blockCategory, + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return blockCategory; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete block category.", + ex.code || "BLOCK_CATEGORY_DELETE_ERROR", + { + keys, + blockCategory + } + ); + } + }; + + const list = async (params: BlockCategoryStorageOperationsListParams) => { + const { where, sort, limit } = params; + + const { tenant, locale, ...restWhere } = where; + const queryAllParams: QueryAllParams = { + entity, + partitionKey: createPartitionKey({ tenant, locale }), + options: { + gt: " " + } + }; + + let items: BlockCategory[] = []; + + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list block categories by given parameters.", + ex.code || "BLOCK_CATEGORIES_LIST_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + + const fields = plugins.byType( + BlockCategoryDynamoDbFieldPlugin.type + ); + + const filteredItems = filterItems({ + plugins, + where: restWhere, + items, + fields + }); + + const sortedItems = sortItems({ + items: filteredItems, + sort, + fields + }); + + return createListResponse({ + items: sortedItems, + limit: limit || 100000, + totalCount: filteredItems.length, + after: null + }); + }; + + return { + get, + create, + update, + delete: deleteBlockCategory, + list + }; +}; diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/keys.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/keys.ts new file mode 100644 index 00000000000..96846a88233 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/keys.ts @@ -0,0 +1,16 @@ +export interface PartitionKeyParams { + tenant: string; + locale: string; +} +export const createPartitionKey = (params: PartitionKeyParams): string => { + const { tenant, locale } = params; + return `T#${tenant}#L#${locale}#PB#BC`; +}; + +export interface SortKeyParams { + slug: string; +} +export const createSortKey = (params: SortKeyParams): string => { + const { slug } = params; + return slug; +}; diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts new file mode 100644 index 00000000000..a053857cd26 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts @@ -0,0 +1,74 @@ +import DataLoader from "dataloader"; +import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { PageBlock } from "@webiny/api-page-builder/types"; +import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import { Entity } from "dynamodb-toolbox"; +import { createPartitionKey, createSortKey } from "./keys"; + +interface Params { + entity: Entity; +} + +type DataLoaderGetItem = Pick; + +export class PageBlockDataLoader { + private _getDataLoader: DataLoader | undefined = undefined; + + private readonly entity: Entity; + + constructor(params: Params) { + this.entity = params.entity; + } + + public async getOne(item: DataLoaderGetItem): Promise { + return await this.getDataLoader().load(item); + } + + public async getAll(items: DataLoaderGetItem[]): Promise { + return await this.getDataLoader().loadMany(items); + } + + public clear(): void { + this.getDataLoader().clearAll(); + } + + private getDataLoader() { + if (!this._getDataLoader) { + const cacheKeyFn = (key: DataLoaderGetItem) => { + return `T#${key.tenant}#L#${key.locale}#${key.id}`; + }; + this._getDataLoader = new DataLoader( + async items => { + const batched = items.map(item => { + return this.entity.getBatch({ + PK: createPartitionKey(item), + SK: createSortKey(item) + }); + }); + + const records = await batchReadAll({ + table: this.entity.table, + items: batched + }); + + const results = records.reduce((collection, result) => { + if (!result) { + return collection; + } + const key = cacheKeyFn(result); + collection[key] = cleanupItem(this.entity, result) as PageBlock; + return collection; + }, {} as Record); + return items.map(item => { + const key = cacheKeyFn(item); + return results[key] || null; + }); + }, + { + cacheKeyFn + } + ); + } + return this._getDataLoader; + } +} diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/fields.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/fields.ts new file mode 100644 index 00000000000..3a50a1426d3 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/fields.ts @@ -0,0 +1,21 @@ +import { PageBlockDynamoDbFieldPlugin } from "~/plugins/definitions/PageBlockDynamoDbFieldPlugin"; + +export const createPageBlockDynamoDbFields = (): PageBlockDynamoDbFieldPlugin[] => { + return [ + new PageBlockDynamoDbFieldPlugin({ + field: "id" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "createdOn", + type: "date" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "savedOn", + type: "date" + }), + new PageBlockDynamoDbFieldPlugin({ + field: "createdBy", + path: "createdBy.id" + }) + ]; +}; diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts new file mode 100644 index 00000000000..de3a7472d37 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts @@ -0,0 +1,214 @@ +import WebinyError from "@webiny/error"; +import { + PageBlock, + PageBlockStorageOperations, + PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsDeleteParams, + PageBlockStorageOperationsGetParams, + PageBlockStorageOperationsListParams, + PageBlockStorageOperationsUpdateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import { sortItems } from "@webiny/db-dynamodb/utils/sort"; +import { filterItems } from "@webiny/db-dynamodb/utils/filter"; +import { PageBlockDataLoader } from "./dataLoader"; +import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse"; +import { PageBlockDynamoDbFieldPlugin } from "~/plugins/definitions/PageBlockDynamoDbFieldPlugin"; +import { PluginsContainer } from "@webiny/plugins"; +import { createPartitionKey, createSortKey } from "./keys"; + +const createType = (): string => { + return "pb.pageBlock"; +}; + +export interface CreatePageBlockStorageOperationsParams { + entity: Entity; + plugins: PluginsContainer; +} +export const createPageBlockStorageOperations = ({ + entity, + plugins +}: CreatePageBlockStorageOperationsParams): PageBlockStorageOperations => { + const dataLoader = new PageBlockDataLoader({ + entity + }); + + const get = async (params: PageBlockStorageOperationsGetParams) => { + const { where } = params; + + try { + return await dataLoader.getOne(where); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load page block by given parameters.", + ex.code || "PAGE_BLOCK_GET_ERROR", + { + where + } + ); + } + }; + + const list = async (params: PageBlockStorageOperationsListParams) => { + const { where, sort, limit } = params; + + const { tenant, locale, ...restWhere } = where; + const queryAllParams: QueryAllParams = { + entity, + partitionKey: createPartitionKey({ tenant, locale }), + options: { + gt: " " + } + }; + + let items: PageBlock[] = []; + + try { + items = await queryAll(queryAllParams); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list page blocks by given parameters.", + ex.code || "PAGE_BLOCK_LIST_ERROR", + { + partitionKey: queryAllParams.partitionKey, + options: queryAllParams.options + } + ); + } + + const fields = plugins.byType( + PageBlockDynamoDbFieldPlugin.type + ); + + const filteredItems = filterItems({ + plugins, + where: restWhere, + items, + fields + }); + + const sortedItems = sortItems({ + items: filteredItems, + sort, + fields + }); + + return createListResponse({ + items: sortedItems, + limit: limit || 100000, + totalCount: filteredItems.length, + after: null + }); + }; + + const create = async (params: PageBlockStorageOperationsCreateParams) => { + const { pageBlock } = params; + + const keys = { + PK: createPartitionKey({ + tenant: pageBlock.tenant, + locale: pageBlock.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.put({ + ...pageBlock, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create page block.", + ex.code || "PAGE_BLOCK_CREATE_ERROR", + { + keys + } + ); + } + }; + + const update = async (params: PageBlockStorageOperationsUpdateParams) => { + const { original, pageBlock } = params; + const keys = { + PK: createPartitionKey({ + tenant: original.tenant, + locale: original.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.put({ + ...pageBlock, + TYPE: createType(), + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update page block.", + ex.code || "PAGE_BLOCK_UPDATE_ERROR", + { + keys, + original, + pageBlock + } + ); + } + }; + + const deletePageBlock = async (params: PageBlockStorageOperationsDeleteParams) => { + const { pageBlock } = params; + const keys = { + PK: createPartitionKey({ + tenant: pageBlock.tenant, + locale: pageBlock.locale + }), + SK: createSortKey(pageBlock) + }; + + try { + await entity.delete({ + ...pageBlock, + ...keys + }); + /** + * Always clear data loader cache when modifying the records. + */ + dataLoader.clear(); + + return pageBlock; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete page block.", + ex.code || "PAGE_BLOCK_DELETE_ERROR", + { + keys, + pageBlock + } + ); + } + }; + + return { + get, + list, + create, + update, + delete: deletePageBlock + }; +}; diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/keys.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/keys.ts new file mode 100644 index 00000000000..a8e8431287c --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/keys.ts @@ -0,0 +1,16 @@ +export interface PartitionKeyParams { + tenant: string; + locale: string; +} +export const createPartitionKey = (params: PartitionKeyParams): string => { + const { tenant, locale } = params; + return `T#${tenant}#L#${locale}#PB#B`; +}; + +export interface SortKeyParams { + id: string; +} +export const createSortKey = (params: SortKeyParams): string => { + const { id } = params; + return id; +}; diff --git a/packages/api-page-builder-so-ddb/src/plugins/definitions/BlockCategoryDynamoDbFieldPlugin.ts b/packages/api-page-builder-so-ddb/src/plugins/definitions/BlockCategoryDynamoDbFieldPlugin.ts new file mode 100644 index 00000000000..fa6a105fb33 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/plugins/definitions/BlockCategoryDynamoDbFieldPlugin.ts @@ -0,0 +1,5 @@ +import { FieldPlugin } from "@webiny/db-dynamodb/plugins/definitions/FieldPlugin"; + +export class BlockCategoryDynamoDbFieldPlugin extends FieldPlugin { + public static override readonly type: string = "pageBuilder.dynamodb.field.blockCategory"; +} diff --git a/packages/api-page-builder-so-ddb/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts b/packages/api-page-builder-so-ddb/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts new file mode 100644 index 00000000000..df5015ade89 --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts @@ -0,0 +1,5 @@ +import { FieldPlugin } from "@webiny/db-dynamodb/plugins/definitions/FieldPlugin"; + +export class PageBlockDynamoDbFieldPlugin extends FieldPlugin { + public static override readonly type: string = "pageBuilder.dynamodb.field.pageBlock"; +} diff --git a/packages/api-page-builder-so-ddb/src/types.ts b/packages/api-page-builder-so-ddb/src/types.ts index 214329f4ca3..8bcae681606 100644 --- a/packages/api-page-builder-so-ddb/src/types.ts +++ b/packages/api-page-builder-so-ddb/src/types.ts @@ -18,7 +18,9 @@ export enum ENTITIES { CATEGORIES = "PbCategories", MENUS = "PbMenus", PAGE_ELEMENTS = "PbPageElements", - PAGES = "PbPages" + PAGES = "PbPages", + BLOCK_CATEGORIES = "PbBlockCategories", + PAGE_BLOCKS = "PbPageBlocks" } export interface TableModifier { @@ -28,7 +30,14 @@ export interface TableModifier { export interface PageBuilderStorageOperations extends BasePageBuilderStorageOperations { getTable: () => Table; getEntities: () => Record< - "system" | "settings" | "categories" | "menus" | "pageElements" | "pages", + | "system" + | "settings" + | "categories" + | "menus" + | "pageElements" + | "pages" + | "blockCategories" + | "pageBlocks", Entity >; } diff --git a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts new file mode 100644 index 00000000000..3117210c6db --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -0,0 +1,423 @@ +import useGqlHandler from "./useGqlHandler"; +import { defaultIdentity } from "../tenancySecurity"; + +jest.setTimeout(100000); + +describe("Block Categories CRUD Test", () => { + const { + createBlockCategory, + deleteBlockCategory, + listBlockCategories, + getBlockCategory, + updateBlockCategory, + createPageBlock, + listPageBlocks, + deletePageBlock, + until + } = useGqlHandler(); + + test("create, read, update and delete block categories", async () => { + // Test creating, getting and updating three block categories. + const prefixes = ["block-category-one-", "block-category-two-", "block-category-three-"]; + for (let i = 0; i < 3; i++) { + const prefix = prefixes[i]; + let data = { + slug: `${prefix}slug`, + name: `${prefix}name`, + icon: `${prefix}icon`, + description: `${prefix}description` + }; + + let [response] = await createBlockCategory({ data }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createBlockCategory: { + data: { + ...data, + createdOn: /^20/, + createdBy: defaultIdentity + }, + error: null + } + } + } + }); + + [response] = await getBlockCategory({ slug: data.slug }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + getBlockCategory: { + data: { + ...data, + createdOn: /^20/, + createdBy: defaultIdentity + }, + error: null + } + } + } + }); + + data = { + slug: data.slug, // Slug cannot be changed. + name: data.name + "-UPDATED", + icon: data.icon + "-UPDATED", + description: data.description + "-UPDATED" + }; + + [response] = await updateBlockCategory({ slug: data.slug, data }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + updateBlockCategory: { + data: { + ...data, + createdOn: /^20/, + createdBy: defaultIdentity + }, + error: null + } + } + } + }); + } + + // List should show three block categories. + let [response] = await listBlockCategories(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listBlockCategories: { + data: [ + { + slug: "block-category-one-slug", + name: "block-category-one-name-UPDATED", + icon: "block-category-one-icon-UPDATED", + description: "block-category-one-description-UPDATED", + createdOn: /^20/, + createdBy: defaultIdentity + }, + { + slug: "block-category-two-slug", + name: "block-category-two-name-UPDATED", + icon: "block-category-two-icon-UPDATED", + description: "block-category-two-description-UPDATED", + createdOn: /^20/, + createdBy: defaultIdentity + }, + { + slug: "block-category-three-slug", + name: "block-category-three-name-UPDATED", + icon: "block-category-three-icon-UPDATED", + description: "block-category-three-description-UPDATED", + createdOn: /^20/, + createdBy: defaultIdentity + } + ], + error: null + } + } + } + }); + + // After deleting all block categories, list should be empty. + for (let i = 0; i < 3; i++) { + const prefix = prefixes[i]; + const data = { + slug: `${prefix}slug`, + name: `${prefix}name-UPDATED`, + icon: `${prefix}icon-UPDATED`, + description: `${prefix}description-UPDATED` + }; + + const [response] = await deleteBlockCategory({ slug: data.slug }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: { + ...data, + createdOn: /^20/, + createdBy: defaultIdentity + }, + error: null + } + } + } + }); + } + + // List should show zero categories. + [response] = await listBlockCategories(); + expect(response).toEqual({ + data: { + pageBuilder: { + listBlockCategories: { + data: [], + error: null + } + } + } + }); + }); + + test("cannot create a block category with empty or invalid slug", async () => { + const [emptySlugErrorResponse] = await createBlockCategory({ + data: { + slug: ``, + name: `empty-slug-category-name`, + icon: `empty-slug-category-icon`, + description: `empty-slug-category-description` + } + }); + + const emptySlugError: ErrorOptions = { + code: "VALIDATION_FAILED_INVALID_FIELDS", + message: "Validation failed.", + data: { + invalidFields: { + slug: { + code: "VALIDATION_FAILED_INVALID_FIELD", + data: null, + message: "Value is required." + } + } + } + }; + + expect(emptySlugErrorResponse).toEqual({ + data: { + pageBuilder: { + createBlockCategory: { + data: null, + error: emptySlugError + } + } + } + }); + + const [invalidSlugErrorResponse] = await createBlockCategory({ + data: { + slug: `invalid--slug--category`, + name: `invalid--slug--category--name`, + icon: `invalid--slug--category--icon`, + description: `invalid--slug--category--description` + } + }); + + const invalidSlugError: ErrorOptions = { + code: "VALIDATION_FAILED_INVALID_FIELDS", + message: "Validation failed.", + data: { + invalidFields: { + slug: { + code: "VALIDATION_FAILED_INVALID_FIELD", + data: null, + message: + "Slug must consist of only 'a-z' and '-' and be max 100 characters long (for example: 'some-entry-slug')" + } + } + } + }; + + expect(invalidSlugErrorResponse).toEqual({ + data: { + pageBuilder: { + createBlockCategory: { + data: null, + error: invalidSlugError + } + } + } + }); + }); + + test("cannot get a block category by empty slug", async () => { + const [errorResponse] = await getBlockCategory({ slug: `` }); + + const error: ErrorOptions = { + code: "GET_BLOCK_CATEGORY_ERROR", + data: null, + message: "Could not load block category by empty slug." + }; + + expect(errorResponse).toEqual({ + data: { + pageBuilder: { + getBlockCategory: { + data: null, + error + } + } + } + }); + }); + + test("cannot update a block category by empty slug", async () => { + const [errorResponse] = await updateBlockCategory({ + slug: ``, + data: { + slug: ``, + name: `empty-slug-category-name`, + icon: `empty-slug-category-icon`, + description: `empty-slug-category-description` + } + }); + + const error: ErrorOptions = { + code: "GET_BLOCK_CATEGORY_ERROR", + data: null, + message: "Could not load block category by empty slug." + }; + + expect(errorResponse).toEqual({ + data: { + pageBuilder: { + updateBlockCategory: { + data: null, + error + } + } + } + }); + }); + + test("cannot delete block category if in use by at least one page block", async () => { + await createBlockCategory({ + data: { + slug: `delete-block-cat`, + name: `name`, + icon: `icon`, + description: `description` + } + }); + + const b1 = await createPageBlock({ + data: { + name: `page-block-one-name`, + blockCategory: `delete-block-cat`, + preview: { src: `https://test.com/page-block-one-name/src.jpg` }, + content: { some: `page-block-one-content` } + } + }).then(([res]) => res.data.pageBuilder.createPageBlock.data); + const b2 = await createPageBlock({ + data: { + name: `page-block-two-name`, + blockCategory: `delete-block-cat`, + preview: { src: `https://test.com/page-block-two-name/src.jpg` }, + content: { some: `page-block-two-content` } + } + }).then(([res]) => res.data.pageBuilder.createPageBlock.data); + const b3 = await createPageBlock({ + data: { + name: `page-block-three-name`, + blockCategory: `delete-block-cat`, + preview: { src: `https://test.com/page-block-three-name/src.jpg` }, + content: { some: `page-block-three-content` } + } + }).then(([res]) => res.data.pageBuilder.createPageBlock.data); + + await until( + listPageBlocks, + ([res]: any) => res.data.pageBuilder.listPageBlocks.data.length === 3, + { + tries: 10, + name: "list page blocks before delete" + } + ); + + const error: ErrorOptions = { + code: "CANNOT_DELETE_BLOCK_CATEGORY_PAGE_BLOCK_EXISTING", + data: null, + message: "Cannot delete block category because some page blocks are linked to it." + }; + + const [deleteBlockCategoryResult] = await deleteBlockCategory({ slug: "delete-block-cat" }); + + expect(deleteBlockCategoryResult).toEqual({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: null, + error + } + } + } + }); + + await deletePageBlock({ id: b1.id }); + + const [deleteBlockCategoryAfterDeletePageBlock1Result] = await deleteBlockCategory({ + slug: "delete-block-cat" + }); + + expect(deleteBlockCategoryAfterDeletePageBlock1Result).toEqual({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: null, + error + } + } + } + }); + + await deletePageBlock({ id: b2.id }); + + const [deleteBlockCategoryAfterDeletePageBlock2Result] = await deleteBlockCategory({ + slug: "delete-block-cat" + }); + + expect(deleteBlockCategoryAfterDeletePageBlock2Result).toEqual({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: null, + error + } + } + } + }); + + const [deletePageBlockResponse] = await deletePageBlock({ id: b3.id }); + + expect(deletePageBlockResponse).toEqual({ + data: { + pageBuilder: { + deletePageBlock: { + data: { + ...b3 + }, + error: null + } + } + } + }); + + await until( + listPageBlocks, + ([res]: any) => { + return res.data.pageBuilder.listPageBlocks.data.length === 0; + }, + { + tries: 10, + name: "list page blocks after delete" + } + ); + + await deleteBlockCategory({ slug: "delete-block-cat" }).then(([res]) => + expect(res.data.pageBuilder.deleteBlockCategory).toMatchObject({ + data: { + createdBy: defaultIdentity, + slug: `delete-block-cat`, + name: `name`, + icon: `icon`, + description: `description` + }, + error: null + }) + ); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts new file mode 100644 index 00000000000..4df9f1fb8b2 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts @@ -0,0 +1,446 @@ +import useGqlHandler from "./useGqlHandler"; +import { identityA, identityB } from "./mocks"; + +function Mock(prefix = "") { + this.slug = `${prefix}slug`; + this.name = `${prefix}name`; + this.icon = `${prefix}icon`; + this.description = `${prefix}description`; +} + +const NOT_AUTHORIZED_RESPONSE = operation => ({ + data: { + pageBuilder: { + [operation]: { + data: null, + error: { + code: "SECURITY_NOT_AUTHORIZED", + data: null, + message: "Not authorized!" + } + } + } + } +}); + +jest.setTimeout(100000); + +const intAsString = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten" +]; + +describe("Block Categories Security Test", () => { + const { createBlockCategory } = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.*" }], + identity: identityA + }); + + test(`"listBlockCategories" only returns entries to which the identity has access to`, async () => { + await createBlockCategory({ data: new Mock("list-block-categories-one-") }); + await createBlockCategory({ data: new Mock("list-block-categories-two-") }); + + const identityBHandler = useGqlHandler({ identity: identityB }); + await identityBHandler.createBlockCategory({ + data: new Mock("list-block-categories-three-") + }); + await identityBHandler.createBlockCategory({ + data: new Mock("list-block-categories-four-") + }); + + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", rwd: "wd" }], identityA], + [[{ name: "pb.block", rwd: "d" }], identityA], + [[{ name: "pb.block", rwd: "w" }], identityA], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ] + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { listBlockCategories } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await listBlockCategories(); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("listBlockCategories")); + } + + const sufficientPermissionsAll = [ + [[{ name: "content.i18n" }, { name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.*" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissionsAll.length; i++) { + const [permissions, identity] = sufficientPermissionsAll[i]; + const { listBlockCategories } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await listBlockCategories(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listBlockCategories: { + data: [ + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-one-slug", + name: "list-block-categories-one-name", + icon: "list-block-categories-one-icon", + description: "list-block-categories-one-description" + }, + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-two-slug", + name: "list-block-categories-two-name", + icon: "list-block-categories-two-icon", + description: "list-block-categories-two-description" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-three-slug", + name: "list-block-categories-three-name", + icon: "list-block-categories-three-icon", + description: "list-block-categories-three-description" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-four-slug", + name: "list-block-categories-four-name", + icon: "list-block-categories-four-icon", + description: "list-block-categories-four-description" + } + ], + error: null + } + } + } + }); + } + + let identityAHandler = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.block", own: true }], + identity: identityA + }); + + let [response] = await identityAHandler.listBlockCategories(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listBlockCategories: { + data: [ + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-one-slug", + name: "list-block-categories-one-name", + icon: "list-block-categories-one-icon", + description: "list-block-categories-one-description" + }, + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-two-slug", + name: "list-block-categories-two-name", + icon: "list-block-categories-two-icon", + description: "list-block-categories-two-description" + } + ], + error: null + } + } + } + }); + + identityAHandler = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.block", own: true }], + identity: identityB + }); + + [response] = await identityAHandler.listBlockCategories(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listBlockCategories: { + data: [ + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-three-slug", + name: "list-block-categories-three-name", + icon: "list-block-categories-three-icon", + description: "list-block-categories-three-description" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-four-slug", + name: "list-block-categories-four-name", + icon: "list-block-categories-four-icon", + description: "list-block-categories-four-description" + } + ], + error: null + } + } + } + }); + }); + + test(`allow createBlockCategory if identity has sufficient permissions`, async () => { + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", own: false, rwd: "r" }], identityA], + [[{ name: "pb.block", own: false, rwd: "rd" }], identityA], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ] + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { createBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + + const [response] = await createBlockCategory({ data: new Mock() }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("createBlockCategory")); + } + + const sufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissions.length; i++) { + const [permissions, identity] = sufficientPermissions[i]; + const { createBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + + const data = new Mock(`block-category-create-${intAsString[i]}-`); + const [response] = await createBlockCategory({ data }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createBlockCategory: { + data, + error: null + } + } + } + }); + } + }); + + test(`allow "updateBlockCategory" if identity has sufficient permissions`, async () => { + const mock = new Mock("update-block-category-"); + + await createBlockCategory({ data: mock }); + + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", rwd: "r" }], identityA], + [[{ name: "pb.block", rwd: "rd" }], identityA], + [[{ name: "pb.block", own: true }], identityB], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA] // will fail - missing "r" + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { updateBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await updateBlockCategory({ slug: mock.slug, data: mock }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("updateBlockCategory")); + } + + const sufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissions.length; i++) { + const [permissions, identity] = sufficientPermissions[i]; + const { updateBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await updateBlockCategory({ slug: mock.slug, data: mock }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + updateBlockCategory: { + data: mock, + error: null + } + } + } + }); + } + }); + + const deleteBlockCategoryInsufficientPermissions = [ + // [[], null], + // [[], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityB], + [[{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "wd" }], identityA] // will fail - missing "r" + ]; + + test.each(deleteBlockCategoryInsufficientPermissions)( + `do not allow "deleteBlockCategory" if identity has not sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("delete-block-category-"); + + await createBlockCategory({ data: mock }); + + const { deleteBlockCategory } = useGqlHandler({ permissions, identity }); + const [response] = await deleteBlockCategory({ slug: mock.slug }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("deleteBlockCategory")); + } + ); + + const deleteBlockCategorySufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [ + [ + { name: "content.i18n" }, + { name: "content.i18n", locales: ["en-US"] }, + { name: "pb.block" } + ], + identityA + ] + ]; + test.each(deleteBlockCategorySufficientPermissions)( + `allow "deleteBlockCategory" if identity has sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("delete-block-category-"); + + const { createBlockCategory, deleteBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + await createBlockCategory({ data: mock }); + const [response] = await deleteBlockCategory({ + slug: mock.slug + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: mock, + error: null + } + } + } + }); + } + ); + + const getBlockCategoryInsufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "wd" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityB], + [[{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], identityA] + ]; + + test.each(getBlockCategoryInsufficientPermissions)( + `do not allow "getBlockCategory" if identity has no sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("get-block-category-"); + await createBlockCategory({ data: mock }); + const { getBlockCategory } = useGqlHandler({ permissions, identity }); + const [response] = await getBlockCategory({ slug: mock.slug, data: mock }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("getBlockCategory")); + } + ); + + const getBlockCategorySufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [ + [ + { name: "content.i18n" }, + { name: "content.i18n", locales: ["en-US"] }, + { name: "pb.block" } + ], + identityA + ] + ]; + + test.each(getBlockCategorySufficientPermissions)( + `allow "getBlockCategory" if identity has sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("get-block-category-"); + + await createBlockCategory({ data: mock }); + + const { getBlockCategory } = useGqlHandler({ permissions, identity }); + const [response] = await getBlockCategory({ slug: mock.slug, data: mock }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + getBlockCategory: { + data: { + ...mock, + createdBy: identityA, + createdOn: /^20/ + }, + error: null + } + } + } + }); + } + ); +}); diff --git a/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts b/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts new file mode 100644 index 00000000000..6b8f30ff3d2 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts @@ -0,0 +1,77 @@ +export const DATA_FIELD = /* GraphQL */ ` + { + slug + name + icon + description + createdOn + createdBy { + id + displayName + type + } + } +`; + +export const ERROR_FIELD = /* GraphQL */ ` + { + code + data + message + } +`; + +export const CREATE_BLOCK_CATEGORY = /* GraphQL */ ` + mutation CreateBlockCategory($data: PbBlockCategoryInput!) { + pageBuilder { + createBlockCategory(data: $data) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const UPDATE_BLOCK_CATEGORY = /* GraphQL */ ` + mutation UpdateBlockCategory($slug: String!, $data: PbBlockCategoryInput!) { + pageBuilder { + updateBlockCategory(slug: $slug, data: $data) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const LIST_BLOCK_CATEGORIES = /* GraphQL */ ` + query ListBlockCategories { + pageBuilder { + listBlockCategories { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const GET_BLOCK_CATEGORY = /* GraphQL */ ` + query GetBlockCategory($slug: String!) { + pageBuilder { + getBlockCategory(slug: $slug) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const DELETE_BLOCK_CATEGORY = /* GraphQL */ ` + mutation DeleteBlockCategory($slug: String!) { + pageBuilder { + deleteBlockCategory(slug: $slug) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts new file mode 100644 index 00000000000..846cf3b2cdd --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts @@ -0,0 +1,78 @@ +export const DATA_FIELD = /* GraphQL */ ` + { + id + blockCategory + preview + name + content + createdOn + createdBy { + id + displayName + type + } + } +`; + +export const ERROR_FIELD = /* GraphQL */ ` + { + code + data + message + } +`; + +export const CREATE_PAGE_BLOCK = /* GraphQL */ ` + mutation CreatePageBlock($data: PbCreatePageBlockInput!) { + pageBuilder { + createPageBlock(data: $data) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const UPDATE_PAGE_BLOCK = /* GraphQL */ ` + mutation UpdatePageBlock($id: ID!, $data: PbUpdatePageBlockInput!) { + pageBuilder { + updatePageBlock(id: $id, data: $data) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const LIST_PAGE_BLOCKS = /* GraphQL */ ` + query ListPageBlocks($where: PbListPageBlocksWhereInput) { + pageBuilder { + listPageBlocks(where: $where) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const GET_PAGE_BLOCK = /* GraphQL */ ` + query GetPageBlock($id: ID!) { + pageBuilder { + getPageBlock(id: $id) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; + +export const DELETE_PAGE_BLOCK = /* GraphQL */ ` + mutation DeletePageBlock($id: ID!) { + pageBuilder { + deletePageBlock(id: $id) { + data ${DATA_FIELD} + error ${ERROR_FIELD} + } + } + } +`; diff --git a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts new file mode 100644 index 00000000000..739db476559 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts @@ -0,0 +1,138 @@ +import useGqlHandler from "./useGqlHandler"; + +import { assignBlockCategoryLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; + +const name = "Block Category Lifecycle Events"; +const slug = "block-category-lifecycle-events"; +const icon = "/block-category-icon"; +const description = "Block Category Description"; + +describe("Block Category Lifecycle Events", () => { + const handler = useGqlHandler({ + plugins: [assignBlockCategoryLifecycleEvents()] + }); + + const { createBlockCategory, updateBlockCategory, deleteBlockCategory } = handler; + + beforeEach(async () => { + tracker.reset(); + }); + + it("should trigger create lifecycle events", async () => { + const [response] = await createBlockCategory({ + data: { + slug, + name, + icon, + description + } + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createBlockCategory: { + data: { + name, + slug, + icon, + description + }, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("block-category:beforeCreate")).toEqual(true); + expect(tracker.isExecutedOnce("block-category:afterCreate")).toEqual(true); + expect(tracker.isExecutedOnce("block-category:beforeUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:beforeDelete")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterDelete")).toEqual(false); + }); + + it("should trigger update lifecycle events", async () => { + await createBlockCategory({ + data: { + slug, + name, + icon, + description + } + }); + + tracker.reset(); + + const [response] = await updateBlockCategory({ + slug: slug, + data: { + slug, + name: `${name} updated`, + icon: `${icon}-updated`, + description: `${name} Updated` + } + }); + + expect(response).toMatchObject({ + data: { + pageBuilder: { + updateBlockCategory: { + data: { + name: `${name} updated`, + slug, + icon: `${icon}-updated`, + description: `${name} Updated` + }, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("block-category:beforeCreate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterCreate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:beforeUpdate")).toEqual(true); + expect(tracker.isExecutedOnce("block-category:afterUpdate")).toEqual(true); + expect(tracker.isExecutedOnce("block-category:beforeDelete")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterDelete")).toEqual(false); + }); + + it("should trigger delete lifecycle events", async () => { + await createBlockCategory({ + data: { + slug, + name, + icon, + description + } + }); + + tracker.reset(); + + const [response] = await deleteBlockCategory({ + slug + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: { + name, + slug, + icon, + description + }, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("block-category:beforeCreate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterCreate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:beforeUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:afterUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("block-category:beforeDelete")).toEqual(true); + expect(tracker.isExecutedOnce("block-category:afterDelete")).toEqual(true); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts new file mode 100644 index 00000000000..e71609b163b --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts @@ -0,0 +1,127 @@ +import useGqlHandler from "./useGqlHandler"; +import { PageBlock } from "~/types"; + +import { assignPageBlockLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; + +const blockCategory = "block-category-lifecycle-events"; + +const pageBlockData = { + name: "Page Block Lifecycle Events", + blockCategory, + preview: { src: "https://test.com/src.jpg" }, + content: { some: "page-block-content" } +}; + +describe("Page Block Lifecycle Events", () => { + const handler = useGqlHandler({ + plugins: [assignPageBlockLifecycleEvents()] + }); + + const { createPageBlock, updatePageBlock, deletePageBlock, createBlockCategory } = handler; + + const createDummyPageBlock = async (): Promise => { + const [response] = await createPageBlock({ + data: pageBlockData + }); + + const pageBlock = response.data?.pageBuilder?.createPageBlock?.data; + if (!pageBlock) { + throw new Error( + response.data?.pageBuilder?.error?.message || + "unknown error while creating dummy pageBlock" + ); + } + return pageBlock; + }; + + let dummyPageBlock: PageBlock; + + beforeEach(async () => { + await createBlockCategory({ + data: { + slug: blockCategory, + name: `name`, + icon: `icon`, + description: `description` + } + }); + // eslint-disable-next-line + dummyPageBlock = await createDummyPageBlock(); + tracker.reset(); + }); + + test("should trigger create lifecycle events", async () => { + const [response] = await createPageBlock({ + data: pageBlockData + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createPageBlock: { + data: pageBlockData, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("pageBlock:beforeCreate")).toEqual(true); + expect(tracker.isExecutedOnce("pageBlock:afterCreate")).toEqual(true); + expect(tracker.isExecutedOnce("pageBlock:beforeUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:beforeDelete")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterDelete")).toEqual(false); + }); + + test("should trigger update lifecycle events", async () => { + const [response] = await updatePageBlock({ + id: dummyPageBlock.id, + data: { + name: `${pageBlockData.name} Updated` + } + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + updatePageBlock: { + data: { + ...pageBlockData, + name: `${pageBlockData.name} Updated` + }, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("pageBlock:beforeCreate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterCreate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:beforeUpdate")).toEqual(true); + expect(tracker.isExecutedOnce("pageBlock:afterUpdate")).toEqual(true); + expect(tracker.isExecutedOnce("pageBlock:beforeDelete")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterDelete")).toEqual(false); + }); + + test("should trigger delete lifecycle events", async () => { + const [response] = await deletePageBlock({ + id: dummyPageBlock.id + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deletePageBlock: { + data: pageBlockData, + error: null + } + } + } + }); + + expect(tracker.isExecutedOnce("pageBlock:beforeCreate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterCreate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:beforeUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:afterUpdate")).toEqual(false); + expect(tracker.isExecutedOnce("pageBlock:beforeDelete")).toEqual(true); + expect(tracker.isExecutedOnce("pageBlock:afterDelete")).toEqual(true); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts index b7cf55c4c21..78b7f8fdf31 100644 --- a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts +++ b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts @@ -160,3 +160,53 @@ export const assignPageElementLifecycleEvents = () => { }); }); }; + +export const assignBlockCategoryLifecycleEvents = () => { + return new ContextPlugin(async context => { + context.pageBuilder.onBeforeBlockCategoryCreate.subscribe(async params => { + tracker.track("block-category:beforeCreate", params); + }); + context.pageBuilder.onAfterBlockCategoryCreate.subscribe(async params => { + tracker.track("block-category:afterCreate", params); + }); + + context.pageBuilder.onBeforeBlockCategoryUpdate.subscribe(async params => { + tracker.track("block-category:beforeUpdate", params); + }); + context.pageBuilder.onAfterBlockCategoryUpdate.subscribe(async params => { + tracker.track("block-category:afterUpdate", params); + }); + + context.pageBuilder.onBeforeBlockCategoryDelete.subscribe(async params => { + tracker.track("block-category:beforeDelete", params); + }); + context.pageBuilder.onAfterBlockCategoryDelete.subscribe(async params => { + tracker.track("block-category:afterDelete", params); + }); + }); +}; + +export const assignPageBlockLifecycleEvents = () => { + return new ContextPlugin(async context => { + context.pageBuilder.onBeforePageBlockCreate.subscribe(async params => { + tracker.track("pageBlock:beforeCreate", params); + }); + context.pageBuilder.onAfterPageBlockCreate.subscribe(async params => { + tracker.track("pageBlock:afterCreate", params); + }); + + context.pageBuilder.onBeforePageBlockUpdate.subscribe(async params => { + tracker.track("pageBlock:beforeUpdate", params); + }); + context.pageBuilder.onAfterPageBlockUpdate.subscribe(async params => { + tracker.track("pageBlock:afterUpdate", params); + }); + + context.pageBuilder.onBeforePageBlockDelete.subscribe(async params => { + tracker.track("pageBlock:beforeDelete", params); + }); + context.pageBuilder.onAfterPageBlockDelete.subscribe(async params => { + tracker.track("pageBlock:afterDelete", params); + }); + }); +}; diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts new file mode 100644 index 00000000000..ef822e69efd --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -0,0 +1,457 @@ +import useGqlHandler from "./useGqlHandler"; +import { defaultIdentity } from "../tenancySecurity"; +import { ErrorOptions } from "@webiny/error"; + +jest.setTimeout(100000); + +describe("Page Blocks Test", () => { + const { + createPageBlock, + getPageBlock, + updatePageBlock, + listPageBlocks, + deletePageBlock, + createBlockCategory + } = useGqlHandler(); + + test("create, read, update and delete page blocks", async () => { + const ids = []; + const prefixes = ["page-block-one-", "page-block-two-", "page-block-three-"]; + + // Create block category + await createBlockCategory({ + data: { + slug: "block-category", + name: "block-category-name", + icon: "block-category-icon", + description: "block-category-description" + } + }); + + // Test creating, getting and updating three page blocks. + for (let i = 0; i < 3; i++) { + const prefix = prefixes[i]; + const data = { + name: `${prefix}name`, + blockCategory: `block-category`, + preview: { src: `https://test.com/${prefix}name/src.jpg` }, + content: { some: `${prefix}content` } + }; + + const [createPageBlockResponse] = await createPageBlock({ data }); + expect(createPageBlockResponse).toMatchObject({ + data: { + pageBuilder: { + createPageBlock: { + data: { + ...data, + createdBy: defaultIdentity, + createdOn: /^20/ + }, + error: null + } + } + } + }); + + ids.push(createPageBlockResponse.data.pageBuilder.createPageBlock.data.id); + + const [getPageBlockResponse] = await getPageBlock({ id: ids[i] }); + expect(getPageBlockResponse).toMatchObject({ + data: { + pageBuilder: { + getPageBlock: { + data, + error: null + } + } + } + }); + + const updateData = { + name: `${prefix}name-UPDATED`, + blockCategory: `block-category`, + preview: { src: `https://test.com/${prefix}name-UPDATED/src.jpg` }, + content: { some: `${prefix}content-UPDATED` } + }; + + const [updatePageBlockResponse] = await updatePageBlock({ + id: ids[i], + data: updateData + }); + expect(updatePageBlockResponse).toMatchObject({ + data: { + pageBuilder: { + updatePageBlock: { + data: { + ...updateData, + createdBy: defaultIdentity, + createdOn: /^20/ + }, + error: null + } + } + } + }); + } + + // List should show three page blocks. + const [listPageBlocksResponse] = await listPageBlocks(); + expect(listPageBlocksResponse).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + blockCategory: "block-category", + content: { + some: "page-block-one-content-UPDATED" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[0], + name: "page-block-one-name-UPDATED", + preview: { + src: "https://test.com/page-block-one-name-UPDATED/src.jpg" + } + }, + { + blockCategory: "block-category", + content: { + some: "page-block-two-content-UPDATED" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[1], + name: "page-block-two-name-UPDATED", + preview: { + src: "https://test.com/page-block-two-name-UPDATED/src.jpg" + } + }, + { + blockCategory: "block-category", + content: { + some: "page-block-three-content-UPDATED" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[2], + name: "page-block-three-name-UPDATED", + preview: { + src: "https://test.com/page-block-three-name-UPDATED/src.jpg" + } + } + ], + error: null + } + } + } + }); + + // After deleting all page blocks, list should be empty. + for (let i = 0; i < 3; i++) { + const [deletePageBlockResponse] = await deletePageBlock({ id: ids[i] }); + expect(deletePageBlockResponse).toMatchObject({ + data: { + pageBuilder: { + deletePageBlock: { + data: { + id: ids[i] + }, + error: null + } + } + } + }); + } + + // List should show zero page blocks. + const [listPageBlocksAfterDeleteResponse] = await listPageBlocks(); + expect(listPageBlocksAfterDeleteResponse).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [], + error: null + } + } + } + }); + }); + + test("cannot create page block with empty or missing block category", async () => { + const [createPageBlockEmptyCategoryResponse] = await createPageBlock({ + data: { + name: "name", + blockCategory: "", + preview: { src: "https://test.com/src.jpg" }, + content: { some: "content" } + } + }); + + expect(createPageBlockEmptyCategoryResponse).toEqual({ + data: { + pageBuilder: { + createPageBlock: { + data: null, + error: { + code: "VALIDATION_FAILED_INVALID_FIELDS", + message: "Validation failed.", + data: { + invalidFields: { + blockCategory: { + code: "VALIDATION_FAILED_INVALID_FIELD", + data: null, + message: "Value is required." + } + } + } + } + } + } + } + }); + + const [createPageBlockInvalidCategoryResponse] = await createPageBlock({ + data: { + name: "name", + blockCategory: "invalid-block-category", + preview: { src: "https://test.com/src.jpg" }, + content: { some: "content" } + } + }); + + const error: ErrorOptions = { + code: "NOT_FOUND", + data: null, + message: "Cannot create page block because failed to find such block category." + }; + + expect(createPageBlockInvalidCategoryResponse).toEqual({ + data: { + pageBuilder: { + createPageBlock: { + data: null, + error + } + } + } + }); + }); + + test("cannot update page block with empty or missing block category", async () => { + await createBlockCategory({ + data: { + slug: "block-category", + name: "block-category-name", + icon: "block-category-icon", + description: "block-category-description" + } + }); + + const [createPageBlockResponse] = await createPageBlock({ + data: { + name: "name", + blockCategory: "block-category", + preview: { src: "https://test.com/src.jpg" }, + content: { some: "content" } + } + }); + + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + + const [updatePageBlockEmptyCategoryResponse] = await updatePageBlock({ + id, + data: { + name: "name", + blockCategory: "", + preview: { src: "https://test.com/src.jpg" }, + content: { some: "content" } + } + }); + + expect(updatePageBlockEmptyCategoryResponse).toEqual({ + data: { + pageBuilder: { + updatePageBlock: { + data: null, + error: { + code: "GET_BLOCK_CATEGORY_ERROR", + data: null, + message: "Could not load block category by empty slug." + } + } + } + } + }); + + const [updatePageBlockInvalidCategoryResponse] = await updatePageBlock({ + id, + data: { + name: "name", + blockCategory: "invalid-block-category", + preview: { src: "https://test.com/src.jpg" }, + content: { some: "content" } + } + }); + + expect(updatePageBlockInvalidCategoryResponse).toEqual({ + data: { + pageBuilder: { + updatePageBlock: { + data: null, + error: { + code: "NOT_FOUND", + data: null, + message: + "Cannot update page block because failed to find such block category." + } + } + } + } + }); + }); + + test("cannot get a page block by empty id", async () => { + const [getPageBlockEmptyIdResponse] = await getPageBlock({ id: "" }); + expect(getPageBlockEmptyIdResponse).toMatchObject({ + data: { + pageBuilder: { + getPageBlock: { + data: null, + error: { + code: "GET_PAGE_BLOCK_ERROR", + data: null, + message: "Could not load page block by empty id." + } + } + } + } + }); + }); + + test(`should able to filter "Page Blocks" list by "blockCategory"`, async () => { + // Create two block categories and two page blocks + await createBlockCategory({ + data: { + slug: "block-category-one", + name: "block-category-one-name", + icon: "block-category-one-icon", + description: "block-category-one-description" + } + }); + + await createBlockCategory({ + data: { + slug: "block-category-two", + name: "block-category-two-name", + icon: "block-category-two-icon", + description: "block-category-two-description" + } + }); + + const [createPageBlockOneResponse] = await createPageBlock({ + data: { + name: `page-block-one-name`, + blockCategory: `block-category-one`, + preview: { src: `https://test.com/page-block-one-name/src.jpg` }, + content: { some: `page-block-one-content` } + } + }); + const pageBlockOneId = createPageBlockOneResponse.data.pageBuilder.createPageBlock.data.id; + + const [createPageBlockTwoResponse] = await createPageBlock({ + data: { + name: `page-block-two-name`, + blockCategory: `block-category-two`, + preview: { src: `https://test.com/page-block-two-name/src.jpg` }, + content: { some: `page-block-two-content` } + } + }); + const pageBlockTwoId = createPageBlockTwoResponse.data.pageBuilder.createPageBlock.data.id; + + //Should list all page blocks from first block category + const [listFirstCategoryPageBlocksResponse] = await listPageBlocks({ + where: { + blockCategory: "block-category-one" + } + }); + expect(listFirstCategoryPageBlocksResponse).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + blockCategory: "block-category-one", + content: { + some: "page-block-one-content" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: pageBlockOneId, + name: "page-block-one-name", + preview: { + src: "https://test.com/page-block-one-name/src.jpg" + } + } + ], + error: null + } + } + } + }); + + //Should list all page blocks from second block category + const [listSecondCategoryPageBlocksResponse] = await listPageBlocks({ + where: { + blockCategory: "block-category-two" + } + }); + expect(listSecondCategoryPageBlocksResponse).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + blockCategory: "block-category-two", + content: { + some: "page-block-two-content" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: pageBlockTwoId, + name: "page-block-two-name", + preview: { + src: "https://test.com/page-block-two-name/src.jpg" + } + } + ], + error: null + } + } + } + }); + }); + + test(`cannot filter "Page Blocks" list by missing "blockCategory"`, async () => { + const [listPageBlocksResponse] = await listPageBlocks({ + where: { + blockCategory: "missing-slug" + } + }); + expect(listPageBlocksResponse).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: null, + error: { + code: "NOT_FOUND", + data: null, + message: "Block Category not found." + } + } + } + } + }); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts new file mode 100644 index 00000000000..4cc61ef9b01 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts @@ -0,0 +1,549 @@ +import useGqlHandler from "./useGqlHandler"; +import { identityA, identityB } from "./mocks"; + +function Mock(prefix = "") { + this.name = `${prefix}name`; + this.blockCategory = `block-category`; + this.preview = { src: `https://test.com/${prefix}name/src.jpg` }; + this.content = { some: `${prefix}content` }; +} + +const NOT_AUTHORIZED_RESPONSE = operation => ({ + data: { + pageBuilder: { + [operation]: { + data: null, + error: { + code: "SECURITY_NOT_AUTHORIZED", + data: null, + message: "Not authorized!" + } + } + } + } +}); + +jest.setTimeout(100000); + +const intAsString = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten" +]; + +describe("Page blocks Security Test", () => { + const { createBlockCategory } = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.*" }], + identity: identityA + }); + + const { createPageBlock } = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.*" }], + identity: identityA + }); + + test(`"listPageBlocks" only returns entries to which the identity has access to`, async () => { + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + await createPageBlock({ data: new Mock("list-page-blocks-one-") }); + await createPageBlock({ data: new Mock("list-page-blocks-two-") }); + + const identityBHandler = useGqlHandler({ identity: identityB }); + await identityBHandler.createPageBlock({ + data: new Mock("list-page-blocks-three-") + }); + await identityBHandler.createPageBlock({ + data: new Mock("list-page-blocks-four-") + }); + + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", rwd: "wd" }], identityA], + [[{ name: "pb.block", rwd: "d" }], identityA], + [[{ name: "pb.block", rwd: "w" }], identityA], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ] + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { listPageBlocks } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await listPageBlocks(); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("listPageBlocks")); + } + + const sufficientPermissionsAll = [ + [[{ name: "content.i18n" }, { name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.*" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissionsAll.length; i++) { + const [permissions, identity] = sufficientPermissionsAll[i]; + const { listPageBlocks } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await listPageBlocks(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + createdBy: identityA, + createdOn: /^20/, + name: "list-page-blocks-one-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-one-name/src.jpg" + }, + content: { some: "list-page-blocks-one-content" } + }, + { + createdBy: identityA, + createdOn: /^20/, + name: "list-page-blocks-two-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-two-name/src.jpg" + }, + content: { some: "list-page-blocks-two-content" } + }, + { + createdBy: identityB, + createdOn: /^20/, + name: "list-page-blocks-three-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-three-name/src.jpg" + }, + content: { some: "list-page-blocks-three-content" } + }, + { + createdBy: identityB, + createdOn: /^20/, + name: "list-page-blocks-four-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-four-name/src.jpg" + }, + content: { some: "list-page-blocks-four-content" } + } + ], + error: null + } + } + } + }); + } + + let identityAHandler = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.block", own: true }], + identity: identityA + }); + + let [response] = await identityAHandler.listPageBlocks(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + createdBy: identityA, + createdOn: /^20/, + name: "list-page-blocks-one-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-one-name/src.jpg" + }, + content: { some: "list-page-blocks-one-content" } + }, + { + createdBy: identityA, + createdOn: /^20/, + name: "list-page-blocks-two-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-two-name/src.jpg" + }, + content: { some: "list-page-blocks-two-content" } + } + ], + error: null + } + } + } + }); + + identityAHandler = useGqlHandler({ + permissions: [{ name: "content.i18n" }, { name: "pb.block", own: true }], + identity: identityB + }); + + [response] = await identityAHandler.listPageBlocks(); + expect(response).toMatchObject({ + data: { + pageBuilder: { + listPageBlocks: { + data: [ + { + createdBy: identityB, + createdOn: /^20/, + name: "list-page-blocks-three-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-three-name/src.jpg" + }, + content: { some: "list-page-blocks-three-content" } + }, + { + createdBy: identityB, + createdOn: /^20/, + name: "list-page-blocks-four-name", + blockCategory: "block-category", + preview: { + src: "https://test.com/list-page-blocks-four-name/src.jpg" + }, + content: { some: "list-page-blocks-four-content" } + } + ], + error: null + } + } + } + }); + }); + + test(`allow "createPageBlock" if identity has sufficient permissions`, async () => { + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", own: false, rwd: "r" }], identityA], + [[{ name: "pb.block", own: false, rwd: "rd" }], identityA], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA] // will fail - missing "r" + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { createPageBlock } = useGqlHandler({ + permissions, + identity: identity as any + }); + + const [response] = await createPageBlock({ data: new Mock() }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("createPageBlock")); + } + + const sufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissions.length; i++) { + const [permissions, identity] = sufficientPermissions[i]; + const { createBlockCategory } = useGqlHandler({ + permissions, + identity: identity as any + }); + const { createPageBlock } = useGqlHandler({ + permissions, + identity: identity as any + }); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + const data = new Mock(`page-block-create-${intAsString[i]}-`); + const [response] = await createPageBlock({ data }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createPageBlock: { + data, + error: null + } + } + } + }); + } + }); + + test(`allow "updatePageBlock" if identity has sufficient permissions`, async () => { + const mock = new Mock("update-page-block-"); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const [createPageBlockResponse] = await createPageBlock({ data: mock }); + + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + + const insufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "pb.block", rwd: "r" }], identityA], + [[{ name: "pb.block", rwd: "rd" }], identityA], + [[{ name: "pb.block", own: true }], identityB], + [ + [{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], + identityA + ], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA] // will fail - missing "r" + ]; + + for (let i = 0; i < insufficientPermissions.length; i++) { + const [permissions, identity] = insufficientPermissions[i]; + const { updatePageBlock } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await updatePageBlock({ id, data: mock }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("updatePageBlock")); + } + + const sufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [[{ name: "content.i18n", locales: ["en-US"] }, { name: "pb.block" }], identityA] + ]; + + for (let i = 0; i < sufficientPermissions.length; i++) { + const [permissions, identity] = sufficientPermissions[i]; + const { updatePageBlock } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [response] = await updatePageBlock({ id, data: mock }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + updatePageBlock: { + data: mock, + error: null + } + } + } + }); + } + }); + + const deletePageBlockInsufficientPermissions = [ + // [[], null], + // [[], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityB], + [[{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "wd" }], identityA] // will fail - missing "r" + ]; + + test.each(deletePageBlockInsufficientPermissions)( + `do not allow "deletePageBlock" if identity has not sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("delete-page-block-"); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const [createPageBlockResponse] = await createPageBlock({ data: mock }); + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + + const { deletePageBlock } = useGqlHandler({ permissions, identity }); + const [response] = await deletePageBlock({ id }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("deletePageBlock")); + } + ); + + const deletePageBlockSufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [ + [ + { name: "content.i18n" }, + { name: "content.i18n", locales: ["en-US"] }, + { name: "pb.block" } + ], + identityA + ] + ]; + + test.each(deletePageBlockSufficientPermissions)( + `allow "deletePageBlock" if identity has sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("delete-page-block-"); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const { createPageBlock, deletePageBlock } = useGqlHandler({ + permissions, + identity: identity as any + }); + const [createPageBlockResponse] = await createPageBlock({ data: mock }); + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + const [response] = await deletePageBlock({ + id + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deletePageBlock: { + data: mock, + error: null + } + } + } + }); + } + ); + + const getPageBlockInsufficientPermissions = [ + [[], null], + [[], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "w" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "wd" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityB], + [[{ name: "content.i18n", locales: ["de-DE", "it-IT"] }, { name: "pb.block" }], identityA] + ]; + + test.each(getPageBlockInsufficientPermissions)( + `do not allow "getPageBlock" if identity has no sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("get-page-block-"); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const [createPageBlockResponse] = await createPageBlock({ data: mock }); + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + const { getPageBlock } = useGqlHandler({ permissions, identity }); + const [response] = await getPageBlock({ id, data: mock }); + expect(response).toMatchObject(NOT_AUTHORIZED_RESPONSE("getPageBlock")); + } + ); + + const getPageBlockSufficientPermissions = [ + [[{ name: "content.i18n" }, { name: "pb.block" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", own: true }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "r" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rw" }], identityA], + [[{ name: "content.i18n" }, { name: "pb.block", rwd: "rwd" }], identityA], + [ + [ + { name: "content.i18n" }, + { name: "content.i18n", locales: ["en-US"] }, + { name: "pb.block" } + ], + identityA + ] + ]; + + test.each(getPageBlockSufficientPermissions)( + `allow "getPageBlock" if identity has sufficient permissions`, + async (permissions: any, identity: any) => { + const mock = new Mock("get-page-block-"); + + await createBlockCategory({ + data: { + slug: `block-category`, + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` + } + }); + + const [createPageBlockResponse] = await createPageBlock({ data: mock }); + const id = createPageBlockResponse.data.pageBuilder.createPageBlock.data.id; + + const { getPageBlock } = useGqlHandler({ permissions, identity }); + const [response] = await getPageBlock({ id, data: mock }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + getPageBlock: { + data: { + ...mock, + createdBy: identityA, + createdOn: /^20/ + }, + error: null + } + } + } + }); + } + ); +}); diff --git a/packages/api-page-builder/__tests__/graphql/pages.test.ts b/packages/api-page-builder/__tests__/graphql/pages.test.ts index f2b3a713a5d..77892dd7197 100644 --- a/packages/api-page-builder/__tests__/graphql/pages.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pages.test.ts @@ -7,8 +7,19 @@ jest.setTimeout(100000); describe("CRUD Test", () => { const handler = useGqlHandler(); - const { createCategory, createPage, deletePage, listPages, getPage, updatePage, until } = - handler; + const { + createBlockCategory, + createCategory, + createPage, + createPageBlock, + createPageElement, + deletePage, + listPages, + getPage, + updatePage, + updatePageBlock, + until + } = handler; test("create, read, update and delete pages", async () => { const [createPageUnknownResponse] = await createPage({ category: "unknown" }); @@ -372,4 +383,104 @@ describe("CRUD Test", () => { } }); }); + + test("get page with resolved reference block", async () => { + // Create page block and add element inside of it + await createBlockCategory({ + data: { + slug: "block-category", + name: "block-category-name", + icon: "block-category-icon", + description: "block-category-description" + } + }); + + const [createPageBlockResponse] = await createPageBlock({ + data: { + name: "block-name", + blockCategory: "block-category", + preview: { src: "https://test.com/src.jpg" }, + content: { data: {}, elements: [], type: "block" } + } + }); + + const blockData = createPageBlockResponse.data.pageBuilder.createPageBlock.data; + + const [createPageElementResponse] = await createPageElement({ + data: { + name: "element-name", + type: "element", + category: "element-category", + preview: { src: "https://test.com/element/src.jpg" }, + content: { some: "element-content" } + } + }); + + const pageElementData = createPageElementResponse.data.pageBuilder.createPageElement.data; + + await updatePageBlock({ + id: blockData.id, + data: { + content: { + ...blockData.content, + elements: [...blockData.content.elements, pageElementData] + } + } + }); + + // Create page + await createCategory({ + data: { + slug: "category-slug", + name: "category-name", + url: "/category-url/", + layout: "category-layout" + } + }); + + const [createPageResponse] = await createPage({ + category: "category-slug" + }); + + const pageId = createPageResponse.data.pageBuilder.createPage.data.id; + + // Add block to the page as reference (without elements) + await updatePage({ + id: pageId, + data: { + content: { + data: {}, + elements: [ + { data: { blockId: blockData.id }, elements: [], path: [], type: "block" } + ], + path: [], + type: "document" + } + } + }); + + // Get page with resolved block (with elements) + const [getPageWithReferenceBlockResponse] = await getPage({ id: pageId }); + + const resolvedBlockData = + getPageWithReferenceBlockResponse.data.pageBuilder.getPage.data.content.elements[0]; + + expect(resolvedBlockData).toMatchObject({ + data: { blockId: blockData.id }, + elements: [ + { + id: pageElementData.id, + category: "element-category", + preview: { src: "https://test.com/element/src.jpg" }, + name: "element-name", + content: { some: "element-content" }, + type: "element", + createdOn: expect.stringMatching(/^20/), + createdBy: defaultIdentity + } + ], + path: [], + type: "block" + }); + }); }); diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index 54e68759a28..2b1a014667a 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -53,6 +53,23 @@ import { } from "./graphql/categories"; import { GET_SETTINGS, GET_DEFAULT_SETTINGS, UPDATE_SETTINGS } from "./graphql/settings"; + +import { + CREATE_BLOCK_CATEGORY, + DELETE_BLOCK_CATEGORY, + LIST_BLOCK_CATEGORIES, + UPDATE_BLOCK_CATEGORY, + GET_BLOCK_CATEGORY +} from "./graphql/blockCategories"; + +import { + CREATE_PAGE_BLOCK, + UPDATE_PAGE_BLOCK, + DELETE_PAGE_BLOCK, + LIST_PAGE_BLOCKS, + GET_PAGE_BLOCK +} from "./graphql/pageBlocks"; + import path from "path"; import fs from "fs"; import { until } from "@webiny/project-utils/testing/helpers/until"; @@ -259,6 +276,40 @@ export default ({ permissions, identity, plugins, storageOperationPlugins }: Par }, async getDefaultSettings(variables = {}) { return invoke({ body: { query: GET_DEFAULT_SETTINGS, variables } }); + }, + + // Block Categories. + async createBlockCategory(variables: Record) { + return invoke({ body: { query: CREATE_BLOCK_CATEGORY, variables } }); + }, + async updateBlockCategory(variables: Record) { + return invoke({ body: { query: UPDATE_BLOCK_CATEGORY, variables } }); + }, + async deleteBlockCategory(variables: Record) { + return invoke({ body: { query: DELETE_BLOCK_CATEGORY, variables } }); + }, + async listBlockCategories(variables = {}) { + return invoke({ body: { query: LIST_BLOCK_CATEGORIES, variables } }); + }, + async getBlockCategory(variables: Record) { + return invoke({ body: { query: GET_BLOCK_CATEGORY, variables } }); + }, + + // Page Blocks. + async createPageBlock(variables: Record) { + return invoke({ body: { query: CREATE_PAGE_BLOCK, variables } }); + }, + async updatePageBlock(variables: Record) { + return invoke({ body: { query: UPDATE_PAGE_BLOCK, variables } }); + }, + async deletePageBlock(variables: Record) { + return invoke({ body: { query: DELETE_PAGE_BLOCK, variables } }); + }, + async listPageBlocks(variables: any = {}) { + return invoke({ body: { query: LIST_PAGE_BLOCKS, variables } }); + }, + async getPageBlock(variables: Record) { + return invoke({ body: { query: GET_PAGE_BLOCK, variables } }); } }; }; diff --git a/packages/api-page-builder/src/graphql/crud.ts b/packages/api-page-builder/src/graphql/crud.ts index 855f204c15e..52cbd513f34 100644 --- a/packages/api-page-builder/src/graphql/crud.ts +++ b/packages/api-page-builder/src/graphql/crud.ts @@ -1,4 +1,6 @@ import { createMenuCrud } from "./crud/menus.crud"; +import { createBlockCategoriesCrud } from "./crud/blockCategories.crud"; +import { createPageBlocksCrud } from "./crud/pageBlocks.crud"; import { createCategoriesCrud } from "./crud/categories.crud"; import { createPageCrud } from "./crud/pages.crud"; import { createPageValidation } from "./crud/pages.validation"; @@ -102,6 +104,20 @@ const setup = (params: CreateCrudParams) => { getLocaleCode }); + const blockCategories = createBlockCategoriesCrud({ + context, + storageOperations, + getTenantId, + getLocaleCode + }); + + const pageBlocks = createPageBlocksCrud({ + context, + storageOperations, + getTenantId, + getLocaleCode + }); + const pageElements = createPageElementsCrud({ context, storageOperations, @@ -123,7 +139,9 @@ const setup = (params: CreateCrudParams) => { ...menus, ...pages, ...pageElements, - ...categories + ...categories, + ...blockCategories, + ...pageBlocks }; if (!storageOperations.init) { diff --git a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts new file mode 100644 index 00000000000..1eb955eb69b --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -0,0 +1,329 @@ +/** + * Package @commodo/fields does not have types. + */ +// @ts-ignore +import { withFields, string } from "@commodo/fields"; +import { validation } from "@webiny/validation"; +import { + BlockCategoriesCrud, + BlockCategory, + BlockCategoryStorageOperationsListParams, + BlockCategoryStorageOperationsGetParams, + OnAfterBlockCategoryCreateTopicParams, + OnAfterBlockCategoryDeleteTopicParams, + OnAfterBlockCategoryUpdateTopicParams, + OnBeforeBlockCategoryCreateTopicParams, + OnBeforeBlockCategoryDeleteTopicParams, + OnBeforeBlockCategoryUpdateTopicParams, + PageBuilderContextObject, + PageBuilderStorageOperations, + PbContext, + PbSecurityPermission +} from "~/types"; +import { NotAuthorizedError } from "@webiny/api-security"; +import hasRwd from "./utils/hasRwd"; +import { NotFoundError } from "@webiny/handler-graphql"; +import checkBasePermissions from "./utils/checkBasePermissions"; +import checkOwnPermissions from "./utils/checkOwnPermissions"; +import WebinyError from "@webiny/error"; +import { createTopic } from "@webiny/pubsub"; + +const CreateDataModel = withFields({ + slug: string({ validation: validation.create("required,slug") }), + name: string({ validation: validation.create("required,minLength:1,maxLength:100") }), + icon: string({ validation: validation.create("required,maxLength:255") }), + description: string({ validation: validation.create("required,minLength:1,maxLength:100") }) +})(); + +const UpdateDataModel = withFields({ + name: string({ validation: validation.create("minLength:1,maxLength:100") }), + icon: string({ validation: validation.create("maxLength:255") }), + description: string({ validation: validation.create("minLength:1,maxLength:100") }) +})(); + +const PERMISSION_NAME = "pb.block"; + +export interface CreateBlockCategoriesCrudParams { + context: PbContext; + storageOperations: PageBuilderStorageOperations; + getTenantId: () => string; + getLocaleCode: () => string; +} +export const createBlockCategoriesCrud = ( + params: CreateBlockCategoriesCrudParams +): BlockCategoriesCrud => { + const { context, storageOperations, getLocaleCode, getTenantId } = params; + + const getPermission = (name: string) => context.security.getPermission(name); + + const onBeforeBlockCategoryCreate = createTopic(); + const onAfterBlockCategoryCreate = createTopic(); + const onBeforeBlockCategoryUpdate = createTopic(); + const onAfterBlockCategoryUpdate = createTopic(); + const onBeforeBlockCategoryDelete = createTopic(); + const onAfterBlockCategoryDelete = createTopic(); + + return { + /** + * Lifecycle events + */ + onBeforeBlockCategoryCreate, + onAfterBlockCategoryCreate, + onBeforeBlockCategoryUpdate, + onAfterBlockCategoryUpdate, + onBeforeBlockCategoryDelete, + onAfterBlockCategoryDelete, + /** + * This method should return category or null. No error throwing on not found. + */ + async getBlockCategory(slug, options = { auth: true }) { + const { auth } = options; + + if (slug === "") { + throw new WebinyError( + "Could not load block category by empty slug.", + "GET_BLOCK_CATEGORY_ERROR" + ); + } + + const params: BlockCategoryStorageOperationsGetParams = { + where: { + slug, + tenant: getTenantId(), + locale: getLocaleCode() + } + }; + + if (auth === false) { + return await storageOperations.blockCategories.get(params); + } + + await context.i18n.checkI18NContentPermission(); + + let permission; + const blocksPermission = await getPermission(PERMISSION_NAME); + if (blocksPermission && hasRwd(blocksPermission, "r")) { + permission = blocksPermission; + } + + if (!permission) { + throw new NotAuthorizedError(); + } + + let blockCategory: BlockCategory | null = null; + try { + blockCategory = await storageOperations.blockCategories.get(params); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load block category by slug.", + ex.code || "GET_BLOCK_CATEGORY_ERROR", + { + ...(ex.data || {}), + params + } + ); + } + if (!blockCategory) { + return null; + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, blockCategory); + + return blockCategory; + }, + + async listBlockCategories() { + await context.i18n.checkI18NContentPermission(); + + let permission: PbSecurityPermission | null = null; + const blocksPermission = await getPermission(PERMISSION_NAME); + if (blocksPermission && hasRwd(blocksPermission, "r")) { + permission = blocksPermission; + } + + if (!permission) { + throw new NotAuthorizedError(); + } + + const params: BlockCategoryStorageOperationsListParams = { + where: { + tenant: getTenantId(), + locale: getLocaleCode() + }, + sort: ["createdOn_ASC"] + }; + // If user can only manage own records, add the createdBy to where values. + if (permission.own) { + const identity = context.security.getIdentity(); + + params.where.createdBy = identity.id; + } + + try { + const [items] = await storageOperations.blockCategories.list(params); + return items; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list block categories by given params.", + ex.code || "LIST_BLOCK_CATEGORY_ERROR", + { + ...(ex.data || {}), + params + } + ); + } + }, + async createBlockCategory(this: PageBuilderContextObject, input) { + await checkBasePermissions(context, PERMISSION_NAME, { rwd: "w" }); + + const createDataModel = new CreateDataModel().populate(input); + await createDataModel.validate(); + + const existingBlockCategory = await this.getBlockCategory(input.slug, { + auth: false + }); + if (existingBlockCategory) { + throw new NotFoundError(`Category with slug "${input.slug}" already exists.`); + } + + const identity = context.security.getIdentity(); + + const data: BlockCategory = await createDataModel.toJSON(); + + const blockCategory: BlockCategory = { + ...data, + createdOn: new Date().toISOString(), + createdBy: { + id: identity.id, + type: identity.type, + displayName: identity.displayName + }, + tenant: getTenantId(), + locale: getLocaleCode() + }; + + try { + await onBeforeBlockCategoryCreate.publish({ + blockCategory + }); + const result = await storageOperations.blockCategories.create({ + input: data, + blockCategory + }); + await onAfterBlockCategoryCreate.publish({ + blockCategory: result + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create block category.", + ex.code || "CREATE_BLOCK_CATEGORY_ERROR", + { + ...(ex.data || {}), + blockCategory + } + ); + } + }, + async updateBlockCategory(this: PageBuilderContextObject, slug, input) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "w" + }); + + const original = await this.getBlockCategory(slug); + if (!original) { + throw new NotFoundError(`Block Category "${slug}" not found.`); + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, original); + + const updateDataModel = new UpdateDataModel().populate(input); + await updateDataModel.validate(); + + const data = await updateDataModel.toJSON({ onlyDirty: true }); + + const blockCategory: BlockCategory = { + ...original, + ...data + }; + try { + await onBeforeBlockCategoryUpdate.publish({ + original, + blockCategory + }); + const result = await storageOperations.blockCategories.update({ + input: data, + original, + blockCategory + }); + await onAfterBlockCategoryUpdate.publish({ + original, + blockCategory + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update block category.", + ex.code || "UPDATE_BLOCK_CATEGORY_ERROR", + { + ...(ex.data || {}), + original, + blockCategory + } + ); + } + }, + async deleteBlockCategory(this: PageBuilderContextObject, slug) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "d" + }); + + const blockCategory = await this.getBlockCategory(slug); + if (!blockCategory) { + throw new NotFoundError(`Block Category "${slug}" not found.`); + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, blockCategory); + + // Before deleting, we need to check if there are any page blocks in this block category. + // If so, prevent delete operation. + const pageBlocks = await this.listPageBlocks({ + where: { + blockCategory: slug + } + }); + + if (pageBlocks?.length > 0) { + throw new WebinyError( + "Cannot delete block category because some page blocks are linked to it.", + "CANNOT_DELETE_BLOCK_CATEGORY_PAGE_BLOCK_EXISTING" + ); + } + + try { + await onBeforeBlockCategoryDelete.publish({ + blockCategory + }); + const result = await storageOperations.blockCategories.delete({ + blockCategory + }); + await onAfterBlockCategoryDelete.publish({ + blockCategory: result + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete block category.", + ex.code || "DELETE_BLOCK_CATEGORY_ERROR", + { + ...(ex.data || {}), + blockCategory + } + ); + } + } + }; +}; diff --git a/packages/api-page-builder/src/graphql/crud/categories.crud.ts b/packages/api-page-builder/src/graphql/crud/categories.crud.ts index f6c9c76fc20..ff9e9c54a51 100644 --- a/packages/api-page-builder/src/graphql/crud/categories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/categories.crud.ts @@ -184,6 +184,9 @@ export const createCategoriesCrud = (params: CreateCategoriesCrudParams): Catego async createCategory(this: PageBuilderContextObject, input) { await checkBasePermissions(context, PERMISSION_NAME, { rwd: "w" }); + const createDataModel = new CreateDataModel().populate(input); + await createDataModel.validate(); + const existingCategory = await this.getCategory(input.slug, { auth: false }); @@ -191,9 +194,6 @@ export const createCategoriesCrud = (params: CreateCategoriesCrudParams): Catego throw new NotFoundError(`Category with slug "${input.slug}" already exists.`); } - const createDataModel = new CreateDataModel().populate(input); - await createDataModel.validate(); - const identity = context.security.getIdentity(); const data: Category = await createDataModel.toJSON(); diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts new file mode 100644 index 00000000000..8f099a06283 --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -0,0 +1,367 @@ +/** + * Package mdbid does not have types. + */ +// @ts-ignore +import mdbid from "mdbid"; +/** + * Package @commodo/fields does not have types. + */ +// @ts-ignore +import { withFields, string } from "@commodo/fields"; +/** + * Package commodo-fields-object does not have types. + */ +// @ts-ignore +import { object } from "commodo-fields-object"; +import { validation } from "@webiny/validation"; +import { + OnAfterPageBlockCreateTopicParams, + OnAfterPageBlockDeleteTopicParams, + OnAfterPageBlockUpdateTopicParams, + OnBeforePageBlockCreateTopicParams, + OnBeforePageBlockDeleteTopicParams, + OnBeforePageBlockUpdateTopicParams, + PageBuilderContextObject, + PageBuilderStorageOperations, + PageBlock, + PageBlocksCrud, + PageBlockStorageOperationsListParams, + PbContext, + Page +} from "~/types"; +import checkBasePermissions from "./utils/checkBasePermissions"; +import checkOwnPermissions from "./utils/checkOwnPermissions"; +import { NotFoundError } from "@webiny/handler-graphql"; +import WebinyError from "@webiny/error"; +import { createTopic } from "@webiny/pubsub"; +import cloneDeep from "lodash/cloneDeep"; + +const CreateDataModel = withFields({ + name: string({ validation: validation.create("required,maxLength:100") }), + blockCategory: string({ validation: validation.create("required,slug") }), + content: object({ validation: validation.create("required") }), + preview: object({ validation: validation.create("required") }) +})(); + +const UpdateDataModel = withFields({ + name: string({ validation: validation.create("maxLength:100") }), + blockCategory: string({ validation: validation.create("slug") }), + content: object(), + preview: object() +})(); + +const PERMISSION_NAME = "pb.block"; + +export interface CreatePageBlocksCrudParams { + context: PbContext; + storageOperations: PageBuilderStorageOperations; + getTenantId: () => string; + getLocaleCode: () => string; +} +export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBlocksCrud => { + const { context, storageOperations, getLocaleCode, getTenantId } = params; + + const onBeforePageBlockCreate = createTopic(); + const onAfterPageBlockCreate = createTopic(); + const onBeforePageBlockUpdate = createTopic(); + const onAfterPageBlockUpdate = createTopic(); + const onBeforePageBlockDelete = createTopic(); + const onAfterPageBlockDelete = createTopic(); + + return { + /** + * Lifecycle events + */ + onBeforePageBlockCreate, + onAfterPageBlockCreate, + onBeforePageBlockUpdate, + onAfterPageBlockUpdate, + onBeforePageBlockDelete, + onAfterPageBlockDelete, + + async getPageBlock(id) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "r" + }); + + if (id === "") { + throw new WebinyError( + "Could not load page block by empty id.", + "GET_PAGE_BLOCK_ERROR" + ); + } + + const params = { + where: { + tenant: getTenantId(), + locale: getLocaleCode(), + id + } + }; + + let pageBlock: PageBlock | null = null; + try { + pageBlock = await storageOperations.pageBlocks.get(params); + if (!pageBlock) { + return null; + } + } catch (ex) { + throw new WebinyError( + ex.message || "Could not get page block by id.", + ex.code || "GET_PAGE_BLOCK_ERROR", + { + ...(ex.data || {}), + params + } + ); + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, pageBlock); + + return pageBlock; + }, + + async listPageBlocks(this: PageBuilderContextObject, params) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "r" + }); + + const { sort, where } = params || {}; + + if (where?.blockCategory) { + const blockCategory = await this.getBlockCategory(where.blockCategory); + + if (!blockCategory) { + throw new NotFoundError(`Block Category not found.`); + } + } + + const listParams: PageBlockStorageOperationsListParams = { + where: { + ...where, + tenant: getTenantId(), + locale: getLocaleCode() + }, + sort: Array.isArray(sort) && sort.length > 0 ? sort : ["createdOn_ASC"] + }; + + // If user can only manage own records, let's add that to the listing. + if (permission.own) { + const identity = context.security.getIdentity(); + listParams.where.createdBy = identity.id; + } + + try { + const [items] = await storageOperations.pageBlocks.list(listParams); + return items; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not list all page blocks.", + ex.code || "LIST_PAGE_BLOCKS_ERROR", + { + params + } + ); + } + }, + + async createPageBlock(this: PageBuilderContextObject, input) { + await checkBasePermissions(context, PERMISSION_NAME, { rwd: "w" }); + + const createDataModel = new CreateDataModel().populate(input); + await createDataModel.validate(); + + const blockCategory = await this.getBlockCategory(input.blockCategory); + if (!blockCategory) { + throw new NotFoundError( + `Cannot create page block because failed to find such block category.` + ); + } + + const id: string = mdbid(); + const identity = context.security.getIdentity(); + + const data: PageBlock = await createDataModel.toJSON(); + + const pageBlock: PageBlock = { + ...data, + tenant: getTenantId(), + locale: getLocaleCode(), + id, + createdOn: new Date().toISOString(), + createdBy: { + id: identity.id, + type: identity.type, + displayName: identity.displayName + } + }; + + try { + await onBeforePageBlockCreate.publish({ + pageBlock + }); + const result = await storageOperations.pageBlocks.create({ + input: data, + pageBlock + }); + await onAfterPageBlockCreate.publish({ + pageBlock + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create page block.", + ex.code || "CREATE_PAGE_BLOCK_ERROR", + { + ...(ex.data || {}), + pageBlock + } + ); + } + }, + + async updatePageBlock(this: PageBuilderContextObject, id, input) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "w" + }); + const original = await this.getPageBlock(id); + if (!original) { + throw new NotFoundError(`Page block "${id}" not found.`); + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, original); + + const updateDataModel = new UpdateDataModel().populate(input); + await updateDataModel.validate(); + + if (input.hasOwnProperty("blockCategory")) { + const blockCategory = await this.getBlockCategory(input.blockCategory); + if (!blockCategory) { + throw new NotFoundError( + `Cannot update page block because failed to find such block category.` + ); + } + } + + const data = await updateDataModel.toJSON({ onlyDirty: true }); + + const pageBlock: PageBlock = { + ...original, + ...data + }; + + try { + await onBeforePageBlockUpdate.publish({ + original, + pageBlock + }); + const result = await storageOperations.pageBlocks.update({ + input: data, + original, + pageBlock + }); + await onAfterPageBlockUpdate.publish({ + original, + pageBlock: result + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update page block.", + ex.code || "UPDATE_PAGE_BLOCK_ERROR", + { + ...(ex.data || {}), + original, + pageBlock + } + ); + } + }, + + async deletePageBlock(this: PageBuilderContextObject, slug) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "d" + }); + + const pageBlock = await this.getPageBlock(slug); + if (!pageBlock) { + throw new NotFoundError(`Page block "${slug}" not found.`); + } + + const identity = context.security.getIdentity(); + checkOwnPermissions(identity, permission, pageBlock); + + try { + await onBeforePageBlockDelete.publish({ + pageBlock + }); + const result = await storageOperations.pageBlocks.delete({ + pageBlock + }); + await onAfterPageBlockDelete.publish({ + pageBlock: result + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete page block.", + ex.code || "DELETE_PAGE_BLOCK_ERROR", + { + ...(ex.data || {}), + pageBlock + } + ); + } + }, + async resolvePageBlocks(this: PageBuilderContextObject, page: Page) { + const blocks = []; + + for (const pageBlock of page.content?.elements) { + const blockId = pageBlock.data?.blockId; + // If block has blockId, then it is a reference block, and we need to get elements for it. + if (!blockId) { + blocks.push(pageBlock); + continue; + } + + const blockData = await storageOperations.pageBlocks.get({ + where: { + tenant: getTenantId(), + locale: getLocaleCode(), + id: blockId + } + }); + // We check if the block has variable values set on the page, and use them + // in priority over the ones set inline in the block editor. + const blockDataVariables = blockData?.content?.data?.variables || []; + const variables = blockDataVariables.map((blockDataVariable: any) => { + const value = + pageBlock.data?.variables?.find( + (variable: any) => variable.id === blockDataVariable.id + )?.value || blockDataVariable.value; + + return { + ...blockDataVariable, + value + }; + }); + + blocks.push( + cloneDeep({ + ...pageBlock, + data: { + blockId, + ...blockData?.content?.data, + variables + }, + elements: blockData?.content?.elements || [] + }) + ); + } + + return blocks; + } + }; +}; 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 8bc2e289444..d1b014b20d8 100644 --- a/packages/api-page-builder/src/graphql/crud/pages.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pages.crud.ts @@ -13,6 +13,7 @@ import { Page, PageBuilderContextObject, PageBuilderStorageOperations, + PageElementProcessor, PagesCrud, PageSecurityPermission, PageStorageOperationsGetWhereParams, @@ -25,6 +26,7 @@ import checkBasePermissions from "./utils/checkBasePermissions"; import checkOwnPermissions from "./utils/checkOwnPermissions"; import normalizePath from "./pages/normalizePath"; import { CreateDataModel, UpdateSettingsModel } from "./pages/models"; +import { processPageContent } from "./pages/processPageContent"; import WebinyError from "@webiny/error"; import lodashTrimEnd from "lodash/trimEnd"; import { @@ -221,6 +223,8 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => { const onBeforePageRequestChanges = createTopic(); const onAfterPageRequestChanges = createTopic(); + const pageElementProcessors: PageElementProcessor[] = []; + return { /** * Lifecycle events @@ -241,6 +245,12 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => { onAfterPageRequestChanges, onBeforePageRequestReview, onAfterPageRequestReview, + addPageElementProcessor(processor) { + pageElementProcessors.push(processor); + }, + async processPageContent(page) { + return processPageContent(page, pageElementProcessors); + }, async createPage(this: PageBuilderContextObject, slug): Promise { await checkBasePermissions(context, PERMISSION_NAME, { rwd: "w" }); diff --git a/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts b/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts new file mode 100644 index 00000000000..0b5776e94ee --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts @@ -0,0 +1,31 @@ +import cloneDeep from "lodash/cloneDeep"; +import { Page, PageElementProcessor, PbPageElement } from "~/types"; + +export async function processPageContent(page: Page, processors: PageElementProcessor[]) { + const processedContent = cloneDeep(page.content) as PbPageElement; + + // Children of the root element of content contain elements of type "block". + for (const block of processedContent.elements) { + await traverse(block, async element => { + for (const processor of processors) { + await processor({ page, block, element }); + } + }); + } + + return { ...page, content: processedContent }; +} + +interface ElementCallback { + (element: PbPageElement): Promise; +} + +async function traverse(element: PbPageElement, callback: ElementCallback) { + if (element.type !== "block") { + await callback(element); + } + + for (const child of element.elements || []) { + await traverse(child, callback); + } +} diff --git a/packages/api-page-builder/src/graphql/elementProcessors/button.ts b/packages/api-page-builder/src/graphql/elementProcessors/button.ts new file mode 100644 index 00000000000..31b4e7fce15 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/button.ts @@ -0,0 +1,25 @@ +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/image.ts b/packages/api-page-builder/src/graphql/elementProcessors/image.ts new file mode 100644 index 00000000000..56d97e34e10 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/image.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000000..53a453a2f54 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/images.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000000..1beecf6f2cd --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/index.ts @@ -0,0 +1,13 @@ +import paragraphElementProcessor from "./paragraph"; +import buttonElementProcessor from "./button"; +import imageElementProcessor from "./image"; +import imagesElementProcessor from "./images"; + +export const createElementProcessors = () => { + return [ + paragraphElementProcessor, + buttonElementProcessor, + imageElementProcessor, + imagesElementProcessor + ]; +}; diff --git a/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts b/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts new file mode 100644 index 00000000000..f38fb54b035 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000000..31b4e7fce15 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/quote.ts @@ -0,0 +1,25 @@ +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 new file mode 100644 index 00000000000..f04a8dfa198 --- /dev/null +++ b/packages/api-page-builder/src/graphql/elementProcessors/useElementVariables.ts @@ -0,0 +1,14 @@ +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) => variable.id.split(".")[0] === element?.data?.variableId + ); + } + + return []; +} diff --git a/packages/api-page-builder/src/graphql/graphql.ts b/packages/api-page-builder/src/graphql/graphql.ts index ab433940ab4..fa11dcab7ef 100644 --- a/packages/api-page-builder/src/graphql/graphql.ts +++ b/packages/api-page-builder/src/graphql/graphql.ts @@ -5,6 +5,9 @@ import { createPageElementsGraphQL } from "./graphql/pageElements.gql"; import { createCategoryGraphQL } from "./graphql/categories.gql"; import { createSettingsGraphQL } from "./graphql/settings.gql"; import { createInstallGraphQL } from "./graphql/install.gql"; +import { createBlockCategoryGraphQL } from "./graphql/blockCategories.gql"; +import { createPageBlockGraphQL } from "./graphql/pageBlocks.gql"; + import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; export default () => { @@ -15,6 +18,8 @@ export default () => { createPageGraphQL(), createPageElementsGraphQL(), createSettingsGraphQL(), - createInstallGraphQL() + createBlockCategoryGraphQL(), + createInstallGraphQL(), + createPageBlockGraphQL ] as GraphQLSchemaPlugin[]; }; diff --git a/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts new file mode 100644 index 00000000000..086a78d50e0 --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts @@ -0,0 +1,90 @@ +import { Response, ErrorResponse } from "@webiny/handler-graphql/responses"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; +import { PbContext } from "../../types"; + +const resolve = async (fn: () => Promise): Promise => { + try { + return new Response(await fn()); + } catch (e) { + return new ErrorResponse(e); + } +}; + +export const createBlockCategoryGraphQL = (): GraphQLSchemaPlugin => { + return { + type: "graphql-schema", + schema: { + typeDefs: /* GraphQL */ ` + type PbBlockCategory { + createdOn: DateTime + createdBy: PbCreatedBy + name: String + slug: String + icon: String + description: String + } + + input PbBlockCategoryInput { + name: String! + slug: String! + icon: String! + description: String! + } + + # Response types + type PbBlockCategoryResponse { + data: PbBlockCategory + error: PbError + } + + type PbBlockCategoryListResponse { + data: [PbBlockCategory] + error: PbError + } + + extend type PbQuery { + getBlockCategory(slug: String!): PbBlockCategoryResponse + listBlockCategories: PbBlockCategoryListResponse + } + + extend type PbMutation { + createBlockCategory(data: PbBlockCategoryInput!): PbBlockCategoryResponse + updateBlockCategory( + slug: String! + data: PbBlockCategoryInput! + ): PbBlockCategoryResponse + deleteBlockCategory(slug: String!): PbBlockCategoryResponse + } + `, + resolvers: { + PbQuery: { + getBlockCategory: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.getBlockCategory(args.slug); + }); + }, + listBlockCategories: async (_, __, context) => { + return resolve(() => context.pageBuilder.listBlockCategories()); + } + }, + PbMutation: { + createBlockCategory: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.createBlockCategory(args.data); + }); + }, + updateBlockCategory: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.updateBlockCategory(args.slug, args.data); + }); + }, + deleteBlockCategory: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.deleteBlockCategory(args.slug); + }); + } + } + } + } + }; +}; diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts new file mode 100644 index 00000000000..854e177d0a8 --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -0,0 +1,89 @@ +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/plugins/GraphQLSchemaPlugin"; + +import resolve from "./utils/resolve"; +import { PbContext } from "../types"; + +export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type PbPageBlock { + id: ID + createdOn: DateTime + createdBy: PbCreatedBy + name: String + blockCategory: String + content: JSON + preview: JSON + } + + input PbCreatePageBlockInput { + name: String! + blockCategory: String! + content: JSON! + preview: JSON! + } + + input PbUpdatePageBlockInput { + name: String + blockCategory: String + content: JSON + preview: JSON + } + + input PbListPageBlocksWhereInput { + blockCategory: String + } + + # Response types + type PbPageBlockResponse { + data: PbPageBlock + error: PbError + } + + type PbPageBlockListResponse { + data: [PbPageBlock] + error: PbError + } + + extend type PbQuery { + listPageBlocks(where: PbListPageBlocksWhereInput): PbPageBlockListResponse + getPageBlock(id: ID!): PbPageBlockResponse + } + + extend type PbMutation { + createPageBlock(data: PbCreatePageBlockInput!): PbPageBlockResponse + updatePageBlock(id: ID!, data: PbUpdatePageBlockInput!): PbPageBlockResponse + deletePageBlock(id: ID!): PbPageBlockResponse + } + `, + resolvers: { + PbQuery: { + getPageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.getPageBlock(args.id); + }); + }, + listPageBlocks: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.listPageBlocks(args); + }); + } + }, + PbMutation: { + createPageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.createPageBlock(args.data); + }); + }, + updatePageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.updatePageBlock(args.id, args.data); + }); + }, + deletePageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.deletePageBlock(args.id); + }); + } + } + } +}); 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 21b09427a66..33556028a77 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -278,6 +278,25 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { const settings = await context.pageBuilder.getCurrentSettings(); const websiteUrl = lodashGet(settings, "websiteUrl") || ""; return websiteUrl + page.path; + }, + content: async (page: Page, _, context) => { + if (!page.content?.elements) { + return page.content; + } + + // Map block references + const blocks = await context.pageBuilder.resolvePageBlocks(page); + const pageWithNewContent = { + ...page, + content: { ...page.content, elements: blocks } + }; + + // Run element processors on the full page content for potential transformations. + const processedPage = await context.pageBuilder.processPageContent( + pageWithNewContent + ); + + return processedPage.content; } }, PbPageListItem: { diff --git a/packages/api-page-builder/src/graphql/index.ts b/packages/api-page-builder/src/graphql/index.ts index eedd4cb434f..2e196407077 100644 --- a/packages/api-page-builder/src/graphql/index.ts +++ b/packages/api-page-builder/src/graphql/index.ts @@ -2,6 +2,7 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; import { createCrud, CreateCrudParams } from "./crud"; import graphql from "./graphql"; import upgrades from "./upgrades"; +import { createElementProcessors } from "~/graphql/elementProcessors"; export const createPageBuilderGraphQL = (): GraphQLSchemaPlugin[] => { return graphql(); @@ -9,5 +10,5 @@ export const createPageBuilderGraphQL = (): GraphQLSchemaPlugin[] => { export type ContextParams = CreateCrudParams; export const createPageBuilderContext = (params: ContextParams) => { - return [createCrud(params), upgrades()]; + return [createCrud(params), upgrades(), createElementProcessors()]; }; diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index b04eedde3ed..ccb5eb37272 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -7,6 +7,8 @@ import { Topic } from "@webiny/pubsub/types"; import { RenderEvent, FlushEvent, QueueAddJob } from "@webiny/api-prerendering-service/types"; import { + PageBlock, + BlockCategory, Category, DefaultSettings, Menu, @@ -177,10 +179,39 @@ export interface OnAfterPageRequestChangesTopicParams latestPage: TPage; } +export interface PbPageElement { + id: string; + type: string; + data: any; // TODO: somehow type `data` + elements: PbPageElement[]; +} + +export interface PbBlockVariable { + id: string; + type: string; + label: string; + value: TValue; +} + +interface PageElementProcessorParams { + page: Page; + block: PbPageElement; + element: PbPageElement; +} + +/** + * Element processors modify elements by reference, without creating a new object. + */ +export interface PageElementProcessor { + (params: PageElementProcessorParams): Promise | void; +} + /** * @category Pages */ export interface PagesCrud { + addPageElementProcessor(processor: PageElementProcessor): void; + processPageContent(content: Page): Promise; getPage(id: string, options?: GetPagesOptions): Promise; listLatestPages( args: ListPagesParams, @@ -499,10 +530,150 @@ export interface SystemCrud { onAfterInstall: Topic; } +export interface PbBlockCategoryInput { + name: string; + slug: string; + icon: string; + description: string; +} + +/** + * @category Lifecycle events + */ +export interface OnBeforeBlockCategoryCreateTopicParams { + blockCategory: BlockCategory; +} +/** + * @category Lifecycle events + */ +export interface OnAfterBlockCategoryCreateTopicParams { + blockCategory: BlockCategory; +} +/** + * @category Lifecycle events + */ +export interface OnBeforeBlockCategoryUpdateTopicParams { + original: BlockCategory; + blockCategory: BlockCategory; +} +/** + * @category Lifecycle events + */ +export interface OnAfterBlockCategoryUpdateTopicParams { + original: BlockCategory; + blockCategory: BlockCategory; +} +/** + * @category Lifecycle events + */ +export interface OnBeforeBlockCategoryDeleteTopicParams { + blockCategory: BlockCategory; +} +/** + * @category Lifecycle events + */ +export interface OnAfterBlockCategoryDeleteTopicParams { + blockCategory: BlockCategory; +} + +/** + * @category BlockCategories + */ +export interface BlockCategoriesCrud { + getBlockCategory(slug: string, options?: { auth: boolean }): Promise; + listBlockCategories(): Promise; + createBlockCategory(data: PbBlockCategoryInput): Promise; + updateBlockCategory(slug: string, data: PbBlockCategoryInput): Promise; + deleteBlockCategory(slug: string): Promise; + /** + * Lifecycle events + */ + onBeforeBlockCategoryCreate: Topic; + onAfterBlockCategoryCreate: Topic; + onBeforeBlockCategoryUpdate: Topic; + onAfterBlockCategoryUpdate: Topic; + onBeforeBlockCategoryDelete: Topic; + onAfterBlockCategoryDelete: Topic; +} + +export interface ListPageBlocksParams { + sort?: string[]; + where?: { + blockCategory?: string; + }; +} +/** + * @category Lifecycle events + */ +export interface OnBeforePageBlockCreateTopicParams { + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnAfterPageBlockCreateTopicParams { + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnBeforePageBlockUpdateTopicParams { + original: PageBlock; + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnAfterPageBlockUpdateTopicParams { + original: PageBlock; + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnBeforePageBlockDeleteTopicParams { + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnAfterPageBlockDeleteTopicParams { + pageBlock: PageBlock; +} + +/** + * @category PageBlocks + */ +export interface PageBlocksCrud { + getPageBlock(id: string): Promise; + listPageBlocks(params?: ListPageBlocksParams): Promise; + createPageBlock(data: Record): Promise; + updatePageBlock(id: string, data: Record): Promise; + deletePageBlock(id: string): Promise; + resolvePageBlocks(page: Page): Promise; + + /** + * Lifecycle events + */ + onBeforePageBlockCreate: Topic; + onAfterPageBlockCreate: Topic; + onBeforePageBlockUpdate: Topic; + onAfterPageBlockUpdate: Topic; + onBeforePageBlockDelete: Topic; + onAfterPageBlockDelete: Topic; +} + export interface PageBuilderContextObject extends PagesCrud, PageElementsCrud, CategoriesCrud, + BlockCategoriesCrud, + PageBlocksCrud, MenusCrud, SettingsCrud, SystemCrud { @@ -540,14 +711,6 @@ export interface PbSecurityPermission extends SecurityPermission { rwd?: string; } -export interface MenuSecurityPermission extends PbSecurityPermission { - name: "pb.menu"; -} - -export interface CategorySecurityPermission extends PbSecurityPermission { - name: "pb.category"; -} - export interface PageSecurityPermission extends PbSecurityPermission { name: "pb.page"; diff --git a/packages/api-page-builder/src/prerendering/hooks/afterPageBlockUpdate.ts b/packages/api-page-builder/src/prerendering/hooks/afterPageBlockUpdate.ts new file mode 100644 index 00000000000..3a25509fc44 --- /dev/null +++ b/packages/api-page-builder/src/prerendering/hooks/afterPageBlockUpdate.ts @@ -0,0 +1,15 @@ +import { ContextPlugin } from "@webiny/handler"; +import { PbContext } from "~/graphql/types"; + +export default () => { + return new ContextPlugin(async ({ pageBuilder }) => { + /** + * After a block has changed, rerender published pages. + */ + pageBuilder.onAfterPageBlockUpdate.subscribe(async ({ pageBlock }) => { + await pageBuilder.prerendering.render({ + tags: [{ tag: { key: "pb-page-block", value: pageBlock.id } }] + }); + }); + }); +}; diff --git a/packages/api-page-builder/src/prerendering/hooks/index.ts b/packages/api-page-builder/src/prerendering/hooks/index.ts index 2cea3142377..929bfdbe9bd 100644 --- a/packages/api-page-builder/src/prerendering/hooks/index.ts +++ b/packages/api-page-builder/src/prerendering/hooks/index.ts @@ -1,4 +1,5 @@ import afterMenuUpdate from "./afterMenuUpdate"; +import afterPageBlockUpdate from "./afterPageBlockUpdate"; import afterPageDelete from "./afterPageDelete"; import afterPagePublish from "./afterPagePublish"; import afterPageUnpublish from "./afterPageUnpublish"; @@ -9,6 +10,7 @@ import { ContextPlugin } from "@webiny/handler"; export default (): ContextPlugin[] => { return [ afterMenuUpdate(), + afterPageBlockUpdate(), afterPageDelete(), afterPagePublish(), afterPageUnpublish(), diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 2ae45a04a29..40b5e200efa 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -11,7 +11,7 @@ export interface PageElement { name: string; type: "element" | "block"; category: string; - content: File; + content: any; preview: File; createdOn: string; createdBy: CreatedBy; @@ -706,6 +706,8 @@ export interface PageBuilderStorageOperations { menus: MenuStorageOperations; pageElements: PageElementStorageOperations; pages: PageStorageOperations; + blockCategories: BlockCategoryStorageOperations; + pageBlocks: PageBlockStorageOperations; beforeInit?: (context: PbContext) => Promise; init?: (context: PbContext) => Promise; @@ -714,3 +716,193 @@ export interface PageBuilderStorageOperations { */ upgrade?: UpgradePlugin | null; } + +/** + * @category RecordModel + */ +export interface BlockCategory { + name: string; + slug: string; + icon: string; + description: string; + createdOn: string; + createdBy: CreatedBy; + tenant: string; + locale: string; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperationsGetParams { + where: { + slug: string; + tenant: string; + locale: string; + }; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperationsListParams { + where: { + tenant: string; + locale: string; + createdBy?: string; + }; + sort?: string[]; + limit?: number; + after?: string | null; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export type BlockCategoryStorageOperationsListResponse = [BlockCategory[], MetaResponse]; + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperationsCreateParams { + input: Record; + blockCategory: BlockCategory; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperationsUpdateParams { + input: Record; + original: BlockCategory; + blockCategory: BlockCategory; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperationsDeleteParams { + blockCategory: BlockCategory; +} + +/** + * @category StorageOperations + * @category BlockCategoryStorageOperations + */ +export interface BlockCategoryStorageOperations { + /** + * Get a single block category item by given params. + */ + get(params: BlockCategoryStorageOperationsGetParams): Promise; + /** + * Get all block categories items by given params. + */ + list( + params: BlockCategoryStorageOperationsListParams + ): Promise; + create(params: BlockCategoryStorageOperationsCreateParams): Promise; + update(params: BlockCategoryStorageOperationsUpdateParams): Promise; + delete(params: BlockCategoryStorageOperationsDeleteParams): Promise; +} + +/** + * @category RecordModel + */ +export interface PageBlock { + id: string; + name: string; + blockCategory: string; + content: any; + preview: File; + createdOn: string; + createdBy: CreatedBy; + tenant: string; + locale: string; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsGetParams { + where: { + id: string; + tenant: string; + locale: string; + }; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsListParams { + where: { + tenant: string; + locale: string; + createdBy?: string; + }; + sort?: string[]; + limit?: number; + after?: string | null; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export type PageBlockStorageOperationsListResponse = [PageBlock[], MetaResponse]; + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsCreateParams { + input: Record; + pageBlock: PageBlock; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsUpdateParams { + input: Record; + original: PageBlock; + pageBlock: PageBlock; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsDeleteParams { + pageBlock: PageBlock; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperations { + /** + * Get a single page block item by given params. + */ + get(params: PageBlockStorageOperationsGetParams): Promise; + /** + * Get all page block items by given params. + */ + list( + params: PageBlockStorageOperationsListParams + ): Promise; + + create(params: PageBlockStorageOperationsCreateParams): Promise; + update(params: PageBlockStorageOperationsUpdateParams): Promise; + delete(params: PageBlockStorageOperationsDeleteParams): Promise; +} diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 8f1c8423655..d7f9acb28b4 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -31,7 +31,7 @@ const PageBuilderProviderPlugin = createProviderPlugin(Component => { const PageBuilderMenu: React.FC = () => { return ( - + }> @@ -56,6 +56,20 @@ const PageBuilderMenu: React.FC = () => { /> + + + + + + diff --git a/packages/app-page-builder/src/editor/plugins/icons/index.tsx b/packages/app-page-builder/src/admin/plugins/icons/index.tsx similarity index 93% rename from packages/app-page-builder/src/editor/plugins/icons/index.tsx rename to packages/app-page-builder/src/admin/plugins/icons/index.tsx index 2f839052b33..c61139e9d38 100644 --- a/packages/app-page-builder/src/editor/plugins/icons/index.tsx +++ b/packages/app-page-builder/src/admin/plugins/icons/index.tsx @@ -1,3 +1,4 @@ +// TODO: find a way to avoid copying and registering icons twice (Headless CMS has an identical plugin) import React from "react"; import { IconName, library } from "@fortawesome/fontawesome-svg-core"; import { fab } from "@fortawesome/free-brands-svg-icons"; diff --git a/packages/app-page-builder/src/admin/plugins/index.ts b/packages/app-page-builder/src/admin/plugins/index.ts index 5918e069305..b2ccc6f6f40 100644 --- a/packages/app-page-builder/src/admin/plugins/index.ts +++ b/packages/app-page-builder/src/admin/plugins/index.ts @@ -7,6 +7,7 @@ import globalSearch from "./globalSearch"; import routes from "./routes"; import installation from "./installation"; import permissionRenderer from "./permissionRenderer"; +import icons from "./icons"; export default () => [ header, @@ -17,5 +18,6 @@ export default () => [ globalSearch, routes, installation, - permissionRenderer + permissionRenderer, + icons ]; diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/header/Header.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/header/Header.tsx index b34fb7890f9..dff223b424a 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/header/Header.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/header/Header.tsx @@ -1,32 +1,32 @@ import React from "react"; -import { css } from "emotion"; +import styled from "@emotion/styled"; import { renderPlugins } from "@webiny/app/plugins"; import { Typography } from "@webiny/ui/Typography"; -import { Grid, Cell } from "@webiny/ui/Grid"; import { PbPageData } from "~/types"; -const headerTitle = css({ - "&.mdc-layout-grid": { - borderBottom: "1px solid var(--mdc-theme-on-background)", - color: "var(--mdc-theme-text-primary-on-background)", - background: "var(--mdc-theme-surface)", - paddingTop: 10, - paddingBottom: 9, - ".mdc-layout-grid__inner": { - alignItems: "center" - } - } +const HeaderTitle = styled("div")({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + borderBottom: "1px solid var(--mdc-theme-on-background)", + color: "var(--mdc-theme-text-primary-on-background)", + background: "var(--mdc-theme-surface)", + paddingTop: 10, + paddingBottom: 9, + paddingLeft: 24, + paddingRight: 24 }); -const pageTitle = css({ +const PageTitle = styled("div")({ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }); -const headerActions = css({ +const HeaderActions = styled("div")({ justifyContent: "flex-end", marginRight: "-15px", + marginLeft: "10px", display: "flex", alignItems: "center" }); @@ -38,15 +38,15 @@ const Header: React.FC = props => { const { page } = props; return ( - - + + {page.title} - - + + {renderPlugins("pb-page-details-header-left", props)} {renderPlugins("pb-page-details-header-right", props)} - - + + ); }; diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/header/deletePage/DeletePage.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/header/deletePage/DeletePage.tsx index afd5f0f929f..fcd365aa5ae 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/header/deletePage/DeletePage.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/header/deletePage/DeletePage.tsx @@ -91,7 +91,6 @@ const DeletePage: React.FC = props => { ); if (!canDelete(page)) { - console.log("Does not have permission to delete page."); return null; } 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 35059deb3ca..9c69d9c9755 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 @@ -69,7 +69,6 @@ const PagePreview: React.FC = ({ page }) => { return ( {({ zoom, setZoom }) => { - console.log("zoom", zoom); return (
= ({ - + diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index e74111401fd..363e878b251 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -9,12 +9,16 @@ import { EditorPluginsLoader } from "../components/EditorPluginsLoader"; import Categories from "../views/Categories/Categories"; import Menus from "../views/Menus/Menus"; import Pages from "../views/Pages/Pages"; +import BlockCategories from "../views/BlockCategories/BlockCategories"; +import PageBlocks from "../views/PageBlocks/PageBlocks"; + import { PageEditor } from "~/pageEditor/Editor"; -// import { BlockEditor } from "~/blockEditor/Editor"; +import { BlockEditor } from "~/blockEditor/Editor"; const ROLE_PB_CATEGORY = "pb.category"; const ROLE_PB_MENUS = "pb.menu"; const ROLE_PB_PAGES = "pb.page"; +const ROLE_PB_BLOCK = "pb.block"; const plugins: RoutePlugin[] = [ { @@ -92,7 +96,43 @@ const plugins: RoutePlugin[] = [ }} /> ) - } /*, + }, + { + name: "route-pb-block-categories", + type: "route", + route: ( + ( + + + + + + + )} + /> + ) + }, + { + name: "route-pb-page-blocks", + type: "route", + route: ( + ( + + + + + + + )} + /> + ) + }, { name: "route-pb-block-editor", type: "route", @@ -112,7 +152,7 @@ const plugins: RoutePlugin[] = [ }} /> ) - }*/ + } ]; export default plugins; diff --git a/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx new file mode 100644 index 00000000000..18ae8c4597a --- /dev/null +++ b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { plugins } from "@webiny/plugins"; +import { PbEditorBlockCategoryPlugin, PbBlockCategory } from "~/types"; + +interface IconProps { + category: PbBlockCategory; +} + +const Icon: React.FC = ({ category }) => { + return ( + + ); +}; + +export default (element: PbBlockCategory): void => { + const plugin: PbEditorBlockCategoryPlugin = { + type: "pb-editor-block-category", + name: "pb-editor-block-category-" + element.slug, + title: element.name, + categoryName: element.slug, + description: element.description || "", + icon: + }; + + plugins.register(plugin); +}; diff --git a/packages/app-page-builder/src/admin/utils/createBlockPlugin.tsx b/packages/app-page-builder/src/admin/utils/createBlockPlugin.tsx index 10c58707533..cd032054016 100644 --- a/packages/app-page-builder/src/admin/utils/createBlockPlugin.tsx +++ b/packages/app-page-builder/src/admin/utils/createBlockPlugin.tsx @@ -2,31 +2,15 @@ import React from "react"; import cloneDeep from "lodash/cloneDeep"; import { plugins } from "@webiny/plugins"; import { Image } from "@webiny/ui/Image"; -import { PbEditorBlockPlugin } from "~/types"; +import { PbEditorBlockPlugin, PbPageBlock } from "~/types"; -export interface BlockElement { - id: string; - name: string; - type: string; - category: string; - content: any; - preview: { - src: string; - meta: { - width: number; - height: number; - aspectRatio: number; - }; - }; -} - -export default (element: BlockElement): void => { +export default (element: PbPageBlock): void => { const plugin: PbEditorBlockPlugin = { id: element.id, name: "pb-saved-block-" + element.id, type: "pb-editor-block", title: element.name, - category: element.category, + blockCategory: element.blockCategory, tags: ["saved"], image: element.preview, create() { diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategories.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategories.tsx new file mode 100644 index 00000000000..9bf7cb9b24c --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategories.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from "react"; +import { SplitView, LeftPanel, RightPanel } from "@webiny/app-admin/components/SplitView"; +import { useSecurity } from "@webiny/app-security"; +import BlockCategoriesDataList from "./BlockCategoriesDataList"; +import BlockCategoriesForm from "./BlockCategoriesForm"; +import { PageBuilderSecurityPermission } from "~/types"; + +const BlockCategories: React.FC = () => { + const { identity, getPermission } = useSecurity(); + const pbMenuPermissionRwd = useMemo((): string | null => { + const permission = getPermission("pb.block"); + if (!permission) { + return null; + } + return permission.rwd || null; + }, [identity]); + + const canCreate = useMemo((): boolean => { + if (typeof pbMenuPermissionRwd === "string") { + return pbMenuPermissionRwd.includes("w"); + } + + return true; + }, []); + + return ( + + + + + + + + + ); +}; + +export default BlockCategories; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx new file mode 100644 index 00000000000..4a1e696d877 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { i18n } from "@webiny/app/i18n"; +import { useRouter } from "@webiny/react-router"; +import { useQuery, useMutation } from "@apollo/react-hooks"; +import { LIST_BLOCK_CATEGORIES, DELETE_BLOCK_CATEGORY } from "./graphql"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; +import orderBy from "lodash/orderBy"; + +import { + DataList, + DataListModalOverlay, + DataListModalOverlayAction, + ScrollList, + ListItem, + ListItemText, + ListItemMeta, + ListActions +} from "@webiny/ui/List"; + +import { DeleteIcon } from "@webiny/ui/List/DataList/icons"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Select } from "@webiny/ui/Select"; +import { useSecurity } from "@webiny/app-security"; +import { ButtonIcon, ButtonSecondary } from "@webiny/ui/Button"; +import SearchUI from "@webiny/app-admin/components/SearchUI"; +import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; +import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/filter-24px.svg"; +import { PageBuilderSecurityPermission, PbBlockCategory } from "~/types"; + +const t = i18n.ns("app-page-builder/admin/block-categories/data-list"); + +interface CreatableItem { + createdBy?: { + id?: string; + }; +} + +interface Sorter { + label: string; + sort: string; +} +const SORTERS: Sorter[] = [ + { + label: t`Newest to oldest`, + sort: "createdOn_DESC" + }, + { + label: t`Oldest to newest`, + sort: "createdOn_ASC" + }, + { + label: t`Name A-Z`, + sort: "name_ASC" + }, + { + label: t`Name Z-A`, + sort: "name_DESC" + } +]; + +type PageBuilderBlockCategoriesDataListProps = { + canCreate: boolean; +}; +const PageBuilderBlockCategoriesDataList = ({ + canCreate +}: PageBuilderBlockCategoriesDataListProps) => { + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState(SORTERS[0].sort); + const { history } = useRouter(); + const { showSnackbar } = useSnackbar(); + const listQuery = useQuery(LIST_BLOCK_CATEGORIES); + const [deleteIt, deleteMutation] = useMutation(DELETE_BLOCK_CATEGORY, { + refetchQueries: [{ query: LIST_BLOCK_CATEGORIES }] + }); + + const filterData = useCallback( + ({ slug, name }) => { + return slug.toLowerCase().includes(filter) || name.toLowerCase().includes(filter); + }, + [filter] + ); + + const sortData = useCallback( + categories => { + if (!sort) { + return categories; + } + const [field, order] = sort.split("_"); + return orderBy(categories, field, order.toLowerCase() as "asc" | "desc"); + }, + [sort] + ); + + const { showConfirmation } = useConfirmationDialog(); + + const data: PbBlockCategory[] = listQuery?.data?.pageBuilder?.listBlockCategories?.data || []; + const slug = new URLSearchParams(location.search).get("slug"); + + const deleteItem = useCallback( + item => { + showConfirmation(async () => { + const response = await deleteIt({ + variables: item + }); + + const error = response?.data?.pageBuilder?.deleteBlockCategory?.error; + if (error) { + return showSnackbar(error.message); + } + + showSnackbar(t`Block Category "{slug}" deleted.`({ slug: item.slug })); + + if (slug === item.slug) { + history.push(`/page-builder/block-categories`); + } + }); + }, + [slug] + ); + + const { identity, getPermission } = useSecurity(); + const pbMenuPermission = useMemo((): PageBuilderSecurityPermission | null => { + return getPermission("pb.block"); + }, [identity]); + + const canDelete = useCallback((item: CreatableItem): boolean => { + if (!pbMenuPermission) { + return false; + } + if (pbMenuPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return item.createdBy?.id === identityId; + } + + if (typeof pbMenuPermission.rwd === "string") { + return pbMenuPermission.rwd.includes("d"); + } + + return true; + }, []); + + const loading = [listQuery, deleteMutation].find(item => item.loading); + + const blockCategoriesDataListModalOverlay = useMemo( + () => ( + + + + + + + + ), + [sort] + ); + + const filteredData: PbBlockCategory[] = filter === "" ? data : data.filter(filterData); + const categoryList: PbBlockCategory[] = sortData(filteredData); + + return ( + history.push("/page-builder/block-categories?new=true")} + > + } /> {t`New Block Category`} + + ) : null + } + search={ + + } + modalOverlay={blockCategoriesDataListModalOverlay} + modalOverlayAction={ + } + data-testid={"default-data-list.filter"} + /> + } + > + {({ data }: { data: PbBlockCategory[] }) => ( + + {data.map(item => ( + + + history.push(`/page-builder/block-categories?slug=${item.slug}`) + } + > + {item.name} + + + {canDelete(item) && ( + + + deleteItem(item)} /> + + + )} + + ))} + + )} + + ); +}; + +export default PageBuilderBlockCategoriesDataList; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx new file mode 100644 index 00000000000..aa5b44789aa --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useMemo } from "react"; +import styled from "@emotion/styled"; +import { i18n } from "@webiny/app/i18n"; +import { Form } from "@webiny/form"; +import { Grid, Cell } from "@webiny/ui/Grid"; +import { ButtonDefault, ButtonIcon, ButtonPrimary } from "@webiny/ui/Button"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { useMutation, useQuery } from "@apollo/react-hooks"; +import { + SimpleForm, + SimpleFormFooter, + SimpleFormContent, + SimpleFormHeader +} from "@webiny/app-admin/components/SimpleForm"; +import IconPicker from "./IconPicker"; +import { validation } from "@webiny/validation"; +import { blockCategorySlugValidator, blockCategoryDescriptionValidator } from "./validators"; +import { + GET_BLOCK_CATEGORY, + CREATE_BLOCK_CATEGORY, + UPDATE_BLOCK_CATEGORY, + LIST_BLOCK_CATEGORIES, + GetBlockCategoryQueryResponse, + GetBlockCategoryQueryVariables, + UpdateBlockCategoryMutationResponse, + UpdateBlockCategoryMutationVariables, + CreateBlockCategoryMutationResponse, + CreateBlockCategoryMutationVariables +} from "./graphql"; +import { useRouter } from "@webiny/react-router"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { Input } from "@webiny/ui/Input"; +import { PageBuilderSecurityPermission, PbBlockCategory } from "~/types"; +import { useSecurity } from "@webiny/app-security"; +import pick from "lodash/pick"; +import get from "lodash/get"; +import set from "lodash/set"; +import isEmpty from "lodash/isEmpty"; +import EmptyView from "@webiny/app-admin/components/EmptyView"; +import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; + +const t = i18n.ns("app-page-builder/admin/block-categories/form"); + +const ButtonWrapper = styled("div")({ + display: "flex", + justifyContent: "space-between" +}); + +interface CategoriesFormProps { + canCreate: boolean; +} +const CategoriesForm: React.FC = ({ canCreate }) => { + const { location, history } = useRouter(); + const { showSnackbar } = useSnackbar(); + + const newEntry = new URLSearchParams(location.search).get("new") === "true"; + const slug = new URLSearchParams(location.search).get("slug"); + + const getQuery = useQuery( + GET_BLOCK_CATEGORY, + { + variables: { + slug: slug as string + }, + skip: !slug, + onCompleted: data => { + const error = data?.pageBuilder?.getBlockCategory?.error; + if (error) { + history.push("/page-builder/block-categories"); + showSnackbar(error.message); + } + } + } + ); + + const loadedBlockCategory = getQuery.data?.pageBuilder?.getBlockCategory?.data || { + slug: null, + createdBy: { + id: null + } + }; + + const [create, createMutation] = useMutation< + CreateBlockCategoryMutationResponse, + CreateBlockCategoryMutationVariables + >(CREATE_BLOCK_CATEGORY, { + refetchQueries: [{ query: LIST_BLOCK_CATEGORIES }] + }); + + const [update, updateMutation] = useMutation< + UpdateBlockCategoryMutationResponse, + UpdateBlockCategoryMutationVariables + >(UPDATE_BLOCK_CATEGORY, { + refetchQueries: [{ query: LIST_BLOCK_CATEGORIES }], + update: (cache, { data }) => { + const blockCategoryDataFromCache = cache.readQuery({ + query: GET_BLOCK_CATEGORY, + variables: { slug } + }) as GetBlockCategoryQueryResponse; + const updatedBlockCategoryData = get(data, "pageBuilder.blockCategory.data"); + + if (updatedBlockCategoryData) { + cache.writeQuery({ + query: GET_BLOCK_CATEGORY, + data: set( + blockCategoryDataFromCache, + "pageBuilder.getBlockCategory.data", + updatedBlockCategoryData + ) + }); + } + } + }); + + const loading = [getQuery, createMutation, updateMutation].find(item => item.loading); + + const onSubmit = useCallback( + async formData => { + const isUpdate = loadedBlockCategory.slug; + const data = pick(formData, ["slug", "name", "icon", "description"]); + + let response; + if (isUpdate) { + response = await update({ + variables: { slug: formData.slug, data } + }); + } else { + response = await create({ + variables: { + data + } + }); + } + + const error = response?.data?.pageBuilder?.blockCategory?.error; + if (error) { + showSnackbar(error.message); + return; + } + + if (!isUpdate) { + history.push(`/page-builder/block-categories?slug=${formData.slug}`); + } + + showSnackbar(t`Block Category saved successfully.`); + }, + [loadedBlockCategory.slug] + ); + + const data = useMemo((): PbBlockCategory => { + return getQuery.data?.pageBuilder?.getBlockCategory.data || ({} as PbBlockCategory); + }, [loadedBlockCategory.slug]); + + const { identity, getPermission } = useSecurity(); + const pbMenuPermission = useMemo((): PageBuilderSecurityPermission | null => { + return getPermission("pb.block"); + }, [identity]); + + const canSave = useMemo((): boolean => { + if (!pbMenuPermission) { + return false; + } + // User should be able to save the form + // if it's a new entry and user has the "own" permission set. + if (!loadedBlockCategory.slug && pbMenuPermission.own) { + return true; + } + + if (pbMenuPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return loadedBlockCategory?.createdBy?.id === identityId; + } + + if (typeof pbMenuPermission.rwd === "string") { + return pbMenuPermission.rwd.includes("w"); + } + + return true; + }, [loadedBlockCategory.slug]); + + const showEmptyView = !newEntry && !loading && isEmpty(data); + // Render "No content selected" view. + if (showEmptyView) { + return ( + history.push("/page-builder/block-categories?new=true")} + > + } /> {t`New Block Category`} + + ) : ( + <> + ) + } + /> + ); + } + + return ( +
+ {({ data, form, Bind }) => ( + + {loading && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + history.push("/page-builder/block-categories")} + >{t`Cancel`} + {canSave && ( + { + form.submit(ev); + }} + >{t`Save block category`} + )} + + + + )} +
+ ); +}; + +export default CategoriesForm; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/IconPicker.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/IconPicker.tsx new file mode 100644 index 00000000000..7a1c7f1eafb --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/IconPicker.tsx @@ -0,0 +1,231 @@ +// TODO: find a better way to share IconPicker with icons across apps. +import * as React from "react"; +import { css } from "emotion"; +import { plugins } from "@webiny/plugins"; +import { Typography } from "@webiny/ui/Typography"; +import { Grid } from "react-virtualized"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; +import { Menu } from "@webiny/ui/Menu"; +import { Input } from "@webiny/ui/Input"; +import { PbIcon, PbIconsPlugin } from "~/types"; +import { FormComponentProps } from "@webiny/ui/types"; +import { FormElementMessage } from "@webiny/ui/FormElementMessage"; +import { GridCellProps } from "react-virtualized/dist/es/Grid"; + +/** + * Controls the helper text below the checkbox. + * @type {string} + */ +const iconPickerLabel = css({ marginBottom: 5, marginLeft: 2 }); + +const MenuWrapper = css({ + color: "var(--mdc-theme-text-secondary-on-background)", + backgroundColor: "var(--mdc-theme-on-background)", + padding: "16px 8px" +}); + +const NoResultWrapper = css({ + width: 640, + color: "var(--mdc-theme-text-secondary-on-background)", + padding: "16px 12px" +}); + +const COLUMN_COUNT = 6; + +const gridItem = css({ + display: "flex", + flexDirection: "column", + justifyContent: "flex-start", + boxSizing: "border-box", + paddingTop: 15, + alignItems: "center", + textAlign: "center", + cursor: "pointer", + transform: "translateZ(0)", + borderRadius: 2, + color: "var(--mdc-theme-text-secondary-on-background)", + transition: "all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)", + "&::after": { + boxShadow: "0 0.25rem 0.125rem 0 rgba(0,0,0,0.05)", + transition: "opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)", + content: '""', + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + zIndex: -1, + opacity: 0 + }, + "&:hover": { + backgroundColor: "var(--mdc-theme-background)", + color: "var(--mdc-theme-text-primary-on-background)", + "&::after": { + opacity: 1 + } + }, + ">svg": { + width: 42, + marginBottom: 5 + } +}); + +const grid = css({ + padding: 20 +}); + +const pickIcon = css({ + width: 50, + textAlign: "center", + cursor: "pointer" +}); + +const searchInput = css({ + input: { + padding: "20px 12px 20px" + } +}); + +const { useState, useCallback, useMemo } = React; + +interface IconPickerProps extends FormComponentProps { + label?: React.ReactNode; + description?: React.ReactNode; +} +const IconPicker: React.FC = ({ + value, + onChange, + label, + description, + validation +}) => { + const [filter, setFilter] = useState(""); + const [mustRenderGrid, setMustRenderGrid] = useState(false); + + const onFilterChange = useCallback( + (value, cb) => { + setFilter(value); + cb(); + }, + [filter] + ); + + const allIcons: PbIcon[] = useMemo(() => { + const iconPlugins = plugins.byType("pb-icons"); + return iconPlugins.reduce((icons: Array, pl) => { + return icons.concat(pl.getIcons()); + }, []); + }, []); + + const icons = useMemo(() => { + return filter ? allIcons.filter(ic => ic.name.includes(filter)) : allIcons; + }, [filter]); + + const renderCell = useCallback( + ({ closeMenu }) => { + return function renderCell({ + columnIndex, + key, + rowIndex, + style + }: GridCellProps): React.ReactNode { + const item = icons[rowIndex * COLUMN_COUNT + columnIndex]; + if (!item) { + return null; + } + + return ( +
{ + if (onChange) { + onChange(item.id.join("/")); + } + closeMenu(); + }} + > + + {item.name} +
+ ); + }; + }, + [icons] + ); + + const renderGrid = useCallback( + ({ closeMenu }) => { + return ( + <> + + {({ value, onChange }) => ( + + )} + + {icons.length === 0 ? ( +
+ No results found. +
+ ) : ( + + )} + + ); + }, + [icons] + ); + + const fontAwesomeIconValue: any = + typeof value === "string" && value.includes("/") ? value.split("/") : ["fas", "star"]; + + const { isValid: validationIsValid, message: validationMessage } = validation || {}; + + return ( + <> + {label && ( +
+ {label} +
+ )} +
+ setMustRenderGrid(true)} + onClose={() => setMustRenderGrid(false)} + handle={ +
+ +
+ } + > + {mustRenderGrid && renderGrid} +
+
+ {validationIsValid === false && ( + {validationMessage} + )} + {validationIsValid !== false && description && ( + {description} + )} + + ); +}; + +export default IconPicker; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts new file mode 100644 index 00000000000..9a5d511306b --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts @@ -0,0 +1,145 @@ +import gql from "graphql-tag"; +import { PbBlockCategory, PbErrorResponse } from "~/types"; + +export const PAGE_BLOCK_CATEGORY_BASE_FIELDS = ` + slug + name + icon + description + createdOn + createdBy { + id + displayName + } +`; + +export const LIST_BLOCK_CATEGORIES = gql` + query ListBlockCategories { + pageBuilder { + listBlockCategories { + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + data + code + message + } + } + } + } +`; +/** + * ########################### + * Get Block Category Query Response + */ +export interface GetBlockCategoryQueryResponse { + pageBuilder: { + getBlockCategory: { + data: PbBlockCategory | null; + error: PbErrorResponse | null; + }; + }; +} +export interface GetBlockCategoryQueryVariables { + slug: string; +} +export const GET_BLOCK_CATEGORY = gql` + query GetBlockCategory($slug: String!) { + pageBuilder { + getBlockCategory(slug: $slug){ + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; +/** + * ########################### + * Create Block Category Mutation Response + */ +export interface CreateBlockCategoryMutationResponse { + pageBuilder: { + blockCategory: { + data: PbBlockCategory | null; + error: PbErrorResponse | null; + }; + }; +} +export interface CreateBlockCategoryMutationVariables { + data: { + name: string; + slug: string; + }; +} +export const CREATE_BLOCK_CATEGORY = gql` + mutation CreateBlockCategory($data: PbBlockCategoryInput!){ + pageBuilder { + blockCategory: createBlockCategory(data: $data) { + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; + +/** + * ########################### + * Update Block Category Mutation Response + */ +export interface UpdateBlockCategoryMutationResponse { + pageBuilder: { + blockCategory: { + data: PbBlockCategory | null; + error: PbErrorResponse | null; + }; + }; +} +export interface UpdateBlockCategoryMutationVariables { + slug: string; + data: { + name: string; + slug: string; + }; +} +export const UPDATE_BLOCK_CATEGORY = gql` + mutation UpdateBlockCategory($slug: String!, $data: PbBlockCategoryInput!){ + pageBuilder { + blockCategory: updateBlockCategory(slug: $slug, data: $data) { + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; + +export const DELETE_BLOCK_CATEGORY = gql` + mutation DeleteBlockCategory($slug: String!) { + pageBuilder { + deleteBlockCategory(slug: $slug) { + error { + code + message + } + } + } + } +`; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts new file mode 100644 index 00000000000..23a1ee1f4bd --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts @@ -0,0 +1,21 @@ +export const blockCategorySlugValidator = (value: string): boolean => { + if (!value.match(/^[a-z]+(\-[a-z]+)*$/)) { + throw new Error( + "Block Category slug must consist of only 'a-z' and '-' characters (for example: 'block-category-slug')" + ); + } + + if (value.length > 100) { + throw new Error("Block Category slug must shorter than 100 characters"); + } + + return true; +}; + +export const blockCategoryDescriptionValidator = (value: string): boolean => { + if (value.length > 100) { + throw new Error("Block Category description must be shorter than 100 characters"); + } + + return true; +}; diff --git a/packages/app-page-builder/src/admin/views/Categories/CategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/Categories/CategoriesDataList.tsx index 79b6d417a2d..722dc4a7256 100644 --- a/packages/app-page-builder/src/admin/views/Categories/CategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/Categories/CategoriesDataList.tsx @@ -107,7 +107,7 @@ const PageBuilderCategoriesDataList = ({ canCreate }: PageBuilderCategoriesDataL variables: item }); - const error = response?.data?.pageBuilder?.deletePageBuilderCategory?.error; + const error = response?.data?.pageBuilder?.deleteCategory?.error; if (error) { return showSnackbar(error.message); } diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx new file mode 100644 index 00000000000..5b693d1d340 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx @@ -0,0 +1,306 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { css } from "emotion"; +import { i18n } from "@webiny/app/i18n"; +import { useRouter } from "@webiny/react-router"; +import { useQuery, useApolloClient } from "@apollo/react-hooks"; +import orderBy from "lodash/orderBy"; +import isEmpty from "lodash/isEmpty"; +import get from "lodash/get"; + +import { + DataList, + DataListModalOverlay, + DataListModalOverlayAction, + ScrollList, + List, + ListItem, + ListItemText, + ListItemTextPrimary, + ListItemTextSecondary +} from "@webiny/ui/List"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Select } from "@webiny/ui/Select"; +import { Typography } from "@webiny/ui/Typography"; +import SearchUI from "@webiny/app-admin/components/SearchUI"; +import { Dialog, DialogTitle, DialogContent, DialogActions } from "@webiny/ui/Dialog"; +import { ButtonDefault, ButtonIcon, ButtonSecondary } from "@webiny/ui/Button"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/filter-24px.svg"; +import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; + +import { PbBlockCategory, PbPageBlock } from "~/types"; +import { LIST_PAGE_BLOCKS_AND_CATEGORIES, LIST_PAGE_BLOCKS, CREATE_PAGE_BLOCK } from "./graphql"; + +import { addElementId } from "~/editor/helpers"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/by-categories-data-list"); + +const narrowDialog = css({ + ".mdc-dialog__surface": { + width: 400, + minWidth: 400 + } +}); + +const noRecordsWrapper = css({ + display: "flex", + justifyContent: "center", + color: "var(--mdc-theme-on-surface)" +}); + +interface Sorter { + label: string; + sort: string; +} +const SORTERS: Sorter[] = [ + { + label: t`Newest to oldest`, + sort: "createdOn_DESC" + }, + { + label: t`Oldest to newest`, + sort: "createdOn_ASC" + }, + { + label: t`Name A-Z`, + sort: "name_ASC" + }, + { + label: t`Name Z-A`, + sort: "name_DESC" + } +]; + +type PageBuilderBlocksByCategoriesDataListProps = { + canCreate: boolean; +}; +const BlocksByCategoriesDataList = ({ canCreate }: PageBuilderBlocksByCategoriesDataListProps) => { + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState(SORTERS[0].sort); + const [isNewPageBlockDialogOpen, setIsNewPageBlockDialogOpen] = useState(false); + const { history } = useRouter(); + const client = useApolloClient(); + const { showSnackbar } = useSnackbar(); + const listQuery = useQuery(LIST_PAGE_BLOCKS_AND_CATEGORIES); + + const blockCategoriesData: PbBlockCategory[] = + listQuery?.data?.pageBuilder?.listBlockCategories?.data || []; + const pageBlocksData: PbPageBlock[] = listQuery?.data?.pageBuilder?.listPageBlocks?.data || []; + + const filterData = useCallback( + ({ slug, name }) => { + return slug.toLowerCase().includes(filter) || name.toLowerCase().includes(filter); + }, + [filter] + ); + + const sortData = useCallback( + categories => { + if (!sort) { + return categories; + } + const [field, order] = sort.split("_"); + return orderBy(categories, field, order.toLowerCase() as "asc" | "desc"); + }, + [sort] + ); + + const selectedBlocksCategory = new URLSearchParams(location.search).get("category"); + const loading = [listQuery].find(item => item.loading); + + const blockCategoriesDataListModalOverlay = useMemo( + () => ( + + + + + + + + ), + [sort] + ); + + const filteredBlockCategoriesData: PbBlockCategory[] = + filter === "" ? blockCategoriesData : blockCategoriesData.filter(filterData); + const categoryList: PbBlockCategory[] = sortData(filteredBlockCategoriesData); + + const onCreatePageBlock = async (categorySlug: string) => { + const { data: res } = await client.mutate({ + mutation: CREATE_PAGE_BLOCK, + variables: { + data: { + name: "New block", + blockCategory: categorySlug, + // Create base block content, and make sure all elements have an `id` property. + content: addElementId({ + type: "block", + // `data` object is required, even if empty. + data: {}, + elements: [ + { + type: "grid", + data: { + settings: { + grid: { + cellsType: "12" + } + } + }, + elements: [ + { + type: "cell", + data: { + settings: { + grid: { + size: 12 + } + } + }, + elements: [] + } + ] + } + ] + }), + preview: {} + } + }, + refetchQueries: [ + { query: LIST_PAGE_BLOCKS_AND_CATEGORIES }, + { query: LIST_PAGE_BLOCKS } + ] + }); + const { error, data } = get(res, `pageBuilder.pageBlock`); + if (data) { + setIsNewPageBlockDialogOpen(false); + history.push(`/page-builder/block-editor/${data.id}`); + } else { + showSnackbar(error.message); + } + }; + + return ( + <> + setIsNewPageBlockDialogOpen(true)}> + } /> {t`New Block`} + + ) : null + } + search={ + + } + modalOverlay={blockCategoriesDataListModalOverlay} + modalOverlayAction={ + } + data-testid={"default-data-list.filter"} + /> + } + refresh={() => { + if (!listQuery.refetch) { + return; + } + listQuery.refetch(); + }} + > + {({ data }: { data: PbBlockCategory[] }) => ( + + {data.map(item => { + const numberOfBlocks = pageBlocksData.filter( + pageBlock => pageBlock.blockCategory === item.slug + ).length; + return ( + + + history.push( + `/page-builder/page-blocks?category=${item.slug}` + ) + } + > + {item.name} + {`${numberOfBlocks} ${ + numberOfBlocks === 1 ? "block" : "blocks" + } in the category`} + + + ); + })} + + )} + + setIsNewPageBlockDialogOpen(false)} + className={narrowDialog} + > + + Please select a block category + + + + {isEmpty(categoryList) ? ( +
+ + There are no block categories + +
+ ) : ( + + {categoryList.map(item => ( + onCreatePageBlock(item.slug)} + > + + {item.name} + + {item.slug} + + + + ))} + + )} +
+
+ + history.push("/page-builder/block-categories?new=true")} + > + + Create a new block category + + +
+ + ); +}; + +export default BlocksByCategoriesDataList; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx new file mode 100644 index 00000000000..3dcffb9695c --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx @@ -0,0 +1,73 @@ +import React, { useMemo, useCallback } from "react"; +import { SplitView, LeftPanel, RightPanel } from "@webiny/app-admin/components/SplitView"; +import { useSecurity } from "@webiny/app-security"; + +import { PageBuilderSecurityPermission } from "~/types"; +import BlocksByCategoriesDataList from "./BlocksByCategoriesDataList"; +import PageBlocksDataList from "./PageBlocksDataList"; + +export interface CreatableItem { + createdBy?: { + id?: string; + }; +} + +const PageBlocks: React.FC = () => { + const { identity, getPermission } = useSecurity(); + const pbPageBlockPermission = useMemo((): PageBuilderSecurityPermission | null => { + return getPermission("pb.block"); + }, [identity]); + + const canCreate = useMemo((): boolean => { + if (!pbPageBlockPermission) { + return false; + } + if (typeof pbPageBlockPermission.rwd === "string") { + return pbPageBlockPermission.rwd.includes("w"); + } + return true; + }, []); + + const canEdit = useCallback((item: CreatableItem): boolean => { + if (!pbPageBlockPermission) { + return false; + } + if (pbPageBlockPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return item.createdBy?.id === identityId; + } + if (typeof pbPageBlockPermission.rwd === "string") { + return pbPageBlockPermission.rwd.includes("w"); + } + + return true; + }, []); + + const canDelete = useCallback((item: CreatableItem): boolean => { + if (!pbPageBlockPermission) { + return false; + } + if (pbPageBlockPermission.own) { + const identityId = identity ? identity.id || identity.login : null; + return item.createdBy?.id === identityId; + } + if (typeof pbPageBlockPermission.rwd === "string") { + return pbPageBlockPermission.rwd.includes("d"); + } + + return true; + }, []); + + return ( + + + + + + + + + ); +}; + +export default PageBlocks; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx new file mode 100644 index 00000000000..16f695bd126 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx @@ -0,0 +1,199 @@ +import React, { useCallback, useEffect } from "react"; +import styled from "@emotion/styled"; +import { useQuery, useMutation } from "@apollo/react-hooks"; +import isEmpty from "lodash/isEmpty"; + +import { useRouter } from "@webiny/react-router"; +import { DeleteIcon, EditIcon } from "@webiny/ui/List/DataList/icons"; +import { CircularProgress } from "@webiny/ui/Progress"; +import EmptyView from "@webiny/app-admin/components/EmptyView"; +import { Typography } from "@webiny/ui/Typography"; +import { i18n } from "@webiny/app/i18n"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; + +import { PbPageBlock } from "~/types"; +import { LIST_PAGE_BLOCKS, LIST_PAGE_BLOCKS_AND_CATEGORIES, DELETE_PAGE_BLOCK } from "./graphql"; +import { CreatableItem } from "./PageBlocks"; +import previewFallback from "./assets/preview.png"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/data-list"); + +const List = styled("div")({ + display: "grid", + rowGap: "20px", + padding: "8px", + margin: "17px 50px", + backgroundColor: "white", + boxShadow: + "0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%)" +}); + +const ListItem = styled("div")({ + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "end", + border: "1px solid rgba(212, 212, 212, 0.5)", + boxShadow: + "0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%)", + minHeight: "70px", + padding: "15px" +}); + +const ListItemText = styled("span")({ + textTransform: "uppercase", + alignSelf: "start", + marginTop: "15px" +}); + +const Controls = styled("div")({ + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + opacity: 0, + backgroundColor: "rgba(0,0,0,0.5)", + transition: "opacity 0.2s ease-out", + + "&:hover": { + opacity: 1 + } +}); + +const DeleteButton = styled(DeleteIcon)({ + position: "absolute", + top: "10px", + right: "10px", + + "& svg": { + fill: "white" + } +}); + +const EditButton = styled(EditIcon)({ + position: "absolute", + top: "10px", + left: "10px", + + "& svg": { + fill: "white" + } +}); + +const NoRecordsWrapper = styled("div")({ + textAlign: "center", + padding: 100, + color: "var(--mdc-theme-on-surface)" +}); + +type PageBlocksDataListProps = { + canEdit: (item: CreatableItem) => boolean; + canDelete: (item: CreatableItem) => boolean; +}; + +const PageBlocksDataList = ({ canEdit, canDelete }: PageBlocksDataListProps) => { + const { history, location } = useRouter(); + const { showSnackbar } = useSnackbar(); + const { showConfirmation } = useConfirmationDialog(); + + const selectedBlocksCategory = new URLSearchParams(location.search).get("category"); + + const { data, loading, refetch } = useQuery(LIST_PAGE_BLOCKS, { + variables: { blockCategory: selectedBlocksCategory as string }, + skip: !selectedBlocksCategory, + onCompleted: data => { + const error = data?.pageBuilder?.listPageBlocks?.error; + if (error) { + history.push("/page-builder/page-blocks"); + showSnackbar(error.message); + } + } + }); + + useEffect(() => { + if (selectedBlocksCategory) { + refetch(); + } + }, [selectedBlocksCategory]); + + const [deleteIt, deleteMutation] = useMutation(DELETE_PAGE_BLOCK, { + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }], //To update block counters on the left side + onCompleted: () => refetch() + }); + + const pageBlocksData: PbPageBlock[] = data?.pageBuilder?.listPageBlocks?.data || []; + + const deleteItem = useCallback( + item => { + showConfirmation(async () => { + const response = await deleteIt({ + variables: item + }); + + const error = response?.data?.pageBuilder?.deletePageBlock?.error; + if (error) { + return showSnackbar(error.message); + } + + showSnackbar(t`Block "{name}" deleted.`({ name: item.name })); + }); + }, + [selectedBlocksCategory] + ); + + const isLoading = [deleteMutation].find(item => item.loading) || loading; + + const showEmptyView = !isLoading && !selectedBlocksCategory; + // Render "No content selected" view. + if (showEmptyView) { + return ( + + ); + } + + const showNoRecordsView = !isLoading && isEmpty(pageBlocksData); + // Render "No records found" view. + if (showNoRecordsView) { + return ( + + No records found. + + ); + } + + return ( + <> + + {isLoading && } + {pageBlocksData.map(pageBlock => ( + + {pageBlock.name} + {pageBlock.name} + + {canEdit(pageBlock) && ( + + history.push(`/page-builder/block-editor/${pageBlock.id}`) + } + /> + )} + {canDelete(pageBlock) && ( + deleteItem(pageBlock)} /> + )} + + + ))} + + + ); +}; + +export default PageBlocksDataList; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/assets/preview.png b/packages/app-page-builder/src/admin/views/PageBlocks/assets/preview.png new file mode 100644 index 00000000000..fced388673b Binary files /dev/null and b/packages/app-page-builder/src/admin/views/PageBlocks/assets/preview.png differ diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts b/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts new file mode 100644 index 00000000000..97d165ee5bc --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts @@ -0,0 +1,184 @@ +import gql from "graphql-tag"; + +import { PbPageBlock, PbErrorResponse } from "~/types"; + +import { PAGE_BLOCK_CATEGORY_BASE_FIELDS } from "~/admin/views/BlockCategories/graphql"; +export { LIST_BLOCK_CATEGORIES } from "~/admin/views/BlockCategories/graphql"; + +const PAGE_BLOCK_BASE_FIELDS = ` + id + blockCategory + preview + name + content + createdOn + createdBy { + id + displayName + type + } +`; + +export const LIST_PAGE_BLOCKS_AND_CATEGORIES = gql` + query ListBlockCategories { + pageBuilder { + listBlockCategories { + data { + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} + } + error { + code + data + message + } + } + listPageBlocks { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + data + message + } + } + } + } +`; +/** + * ############################## + * Get Page Block Query + */ +export interface GetPageBlockQueryResponse { + pageBuilder: { + data?: PbPageBlock; + error?: PbErrorResponse; + }; +} +export interface GetPageBlockQueryVariables { + id: string; +} +export const GET_PAGE_BLOCK = gql` + query GetPageBlocks($id: ID!) { + pageBuilder { + getPageBlock(id: $id) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + data + message + } + } + } + } +`; +/** + * ############################## + * List Page Blocks Query + */ +export interface ListPageBlocksQueryResponse { + pageBuilder: { + data?: PbPageBlock[]; + error?: PbErrorResponse; + }; +} +export interface ListPageBlocksQueryVariables { + blockCategory: string; +} +export const LIST_PAGE_BLOCKS = gql` + query ListPageBlocks($blockCategory: String) { + pageBuilder { + listPageBlocks(where: {blockCategory:$blockCategory}) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + data + message + } + } + } + } +`; +/** + * ########################### + * Create Page Block Mutation Response + */ +export interface CreatePageBlockMutationResponse { + pageBuilder: { + pageBlock: { + data: PbPageBlock | null; + error: PbErrorResponse | null; + }; + }; +} +export interface CreatePageBlockMutationVariables { + data: PbPageBlock; +} +export const CREATE_PAGE_BLOCK = gql` + mutation CreatePageBlock($data: PbCreatePageBlockInput!){ + pageBuilder { + pageBlock: createPageBlock(data: $data) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; +/** + * ########################### + * Update Page Block Mutation Response + */ +export interface UpdatePageBlockMutationResponse { + pageBuilder: { + pageBlock: { + data: PbPageBlock | null; + error: PbErrorResponse | null; + }; + }; +} +export interface UpdatePageBlockMutationVariables { + id: string; + data: { + name: string; + blockCategory: string; + }; +} +export const UPDATE_PAGE_BLOCK = gql` + mutation UpdatePageBlock($id: ID!, $data: PbUpdatePageBlockInput!){ + pageBuilder { + pageBlock: updatePageBlock(id: $id, data: $data) { + data { + ${PAGE_BLOCK_BASE_FIELDS} + } + error { + code + message + data + } + } + } + } +`; + +export const DELETE_PAGE_BLOCK = gql` + mutation DeletePageBlock($id: ID!) { + pageBuilder { + deletePageBlock(id: $id) { + error { + code + message + } + } + } + } +`; diff --git a/packages/app-page-builder/src/blockEditor/Editor.tsx b/packages/app-page-builder/src/blockEditor/Editor.tsx index 07154039ab1..d883cf87504 100644 --- a/packages/app-page-builder/src/blockEditor/Editor.tsx +++ b/packages/app-page-builder/src/blockEditor/Editor.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { useRouter } from "@webiny/react-router"; +import { plugins } from "@webiny/plugins"; import get from "lodash/get"; import { Editor as PbEditor } from "~/admin/components/Editor"; import { EditorLoadingScreen } from "~/admin/components/EditorLoadingScreen"; @@ -9,14 +10,17 @@ import { ListPageElementsQueryResponse, ListPageElementsQueryResponseData } from "~/admin/graphql/pages"; +import { GET_PAGE_BLOCK, ListPageBlocksQueryResponse } from "~/admin/views/PageBlocks/graphql"; import createElementPlugin from "~/admin/utils/createElementPlugin"; -import createBlockPlugin from "~/admin/utils/createBlockPlugin"; import { createStateInitializer } from "./createStateInitializer"; import { BlockEditorConfig } from "./config/BlockEditorConfig"; import { BlockWithContent } from "~/blockEditor/state"; -import { createElement } from "~/editor/helpers"; +import { createElement, addElementId } from "~/editor/helpers"; +import { PbPageBlock, PbEditorElement } from "~/types"; +import elementVariablePlugins from "~/blockEditor/plugins/elementVariables"; export const BlockEditor: React.FC = () => { + plugins.register(elementVariablePlugins()); const client = useApolloClient(); const { params } = useRouter(); const [block, setBlock] = useState(); @@ -36,36 +40,30 @@ export const BlockEditor: React.FC = () => { data: {}, elements: [] }); - } else { - createBlockPlugin({ - ...element - }); } }); }); - // TODO: for full implementation example, see how `pageEditor` loads the initial data. - // NOTE: for Blocks, we don't have revisions, so we don't need to check for states like - // `published`, nor do we need to create new revisions. + const blockData = client + .query({ + query: GET_PAGE_BLOCK, + variables: { id: blockId } + }) + .then(({ data }) => { + const pageBlockData: PbPageBlock = get(data, "pageBuilder.getPageBlock.data"); + + // We need to wrap all elements into a "document" element, it's a requirement for the editor to work. + const content: PbEditorElement = { + ...createElement("document"), + elements: [addElementId(pageBlockData.content)] + }; - // For demo purposes, we create a dummy promise with dummy data. - const document = createElement("document"); - const blockData = new Promise(resolve => { - setBlock({ - id: blockId, - title: "My block", - content: { - ...document, - elements: [createElement("block")] - }, - createdBy: { - id: "123" - } + setBlock({ + ...pageBlockData, + content + }); }); - resolve(); - }); - return React.lazy(() => Promise.all([savedElements, blockData]).then(() => { return { default: ({ children }: { children: React.ReactElement }) => children }; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx new file mode 100644 index 00000000000..0d8e371a13d --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { ReactComponent as InfoIcon } from "@webiny/app-admin/assets/icons/info.svg"; +import CreateVariableAction from "~/blockEditor/plugins/elementSettings/CreateVariableAction"; + +export const ElementLinkStatusWrapper = styled("div")({ + padding: "16px", + display: "grid", + rowGap: "16px", + justifyContent: "center", + alignItems: "center", + margin: "16px", + textAlign: "center", + backgroundColor: "var(--mdc-theme-background)", + border: "3px dashed var(--webiny-theme-color-border)", + borderRadius: "5px", + "& .info-wrapper": { + display: "flex", + alignItems: "center", + fontSize: "10px", + "& svg": { + width: "18px", + marginRight: "5px" + } + } +}); + +const ElementNotLinked = () => { + return ( + + Element not linked + To allow users to change the value of this element inside a page, you need to link it to + a variable. +
+ + Create variable + +
+
+ Click here to learn more about how block variables work +
+
+ ); +}; + +export default ElementNotLinked; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/TextInput.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/TextInput.tsx new file mode 100644 index 00000000000..aa8eb6ac90c --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/TextInput.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useState } from "react"; +import { Input } from "@webiny/ui/Input"; + +interface TextInputProps { + label?: string; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; +} + +const TextInput: React.FC = ({ label, value, onChange, onBlur }) => { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + if (localValue !== value) { + setLocalValue(value); + } + }, [value]); + + return ( + { + onChange(value); + setLocalValue(value); + }} + onBlur={onBlur} + /> + ); +}; + +export default TextInput; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx new file mode 100644 index 00000000000..fdc7111a291 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useMemo } from "react"; +import styled from "@emotion/styled"; +import capitalize from "lodash/capitalize"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { ReactComponent as InfoIcon } from "@webiny/app-admin/assets/icons/info.svg"; +import { PbEditorElement, PbBlockVariable } from "~/types"; +import TextInput from "./TextInput"; +import { useCurrentBlockElement } from "~/editor/hooks/useCurrentBlockElement"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { ElementLinkStatusWrapper } from "./ElementNotLinked"; + +const FormWrapper = styled("div")({ + padding: "16px", + display: "grid", + rowGap: "16px" +}); + +const VariableSettings = ({ element }: { element: PbEditorElement }) => { + const { block } = useCurrentBlockElement(); + const updateElement = useUpdateElement(); + + const elementVariables = useMemo(() => { + const variables = block?.data?.variables?.filter( + (variable: PbBlockVariable) => variable.id.split(".")[0] === element?.data?.variableId + ); + + return variables; + }, [block, element]); + + const onChange = useCallback( + (label: string, variableId: string) => { + if (block && block.id) { + const newVariables = block.data?.variables?.map((variable: PbBlockVariable) => { + if (variable?.id === variableId) { + return { + ...variable, + label + }; + } else { + return variable; + } + }); + updateElement( + { + ...block, + data: { + ...block.data, + variables: newVariables + } + }, + { + history: false + } + ); + } + }, + [block, element] + ); + + const { showConfirmation } = useConfirmationDialog({ + title: "Remove variable", + message:

Are you sure you want to remove element variable?

+ }); + + const onRemove = useCallback( + () => + showConfirmation(() => { + if (block && block.id) { + const updatedVariables = block.data.variables.filter( + (variable: PbBlockVariable) => + variable.id.split(".")[0] !== element?.data?.variableId + ); + updateElement({ + ...block, + data: { + ...block.data, + variables: updatedVariables + } + }); + + // element "variableId" value should be dropped + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { variableId, ...updatedElementData } = element.data; + updateElement( + { + ...element, + data: updatedElementData + }, + { + history: false + } + ); + } + }), + [block, element] + ); + + return ( + <> + + {elementVariables?.map((variable: PbBlockVariable, index: string) => ( + onChange(value, variable.id)} + /> + ))} + + + Element is linked + To prevent users to change the value of this element inside a page, you need to + unlink it from variables. + Unlink Element +
+ Click here to learn more about how block variables work +
+
+ + ); +}; + +export default VariableSettings; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx new file mode 100644 index 00000000000..ddd2e2c6263 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx @@ -0,0 +1,206 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { PbEditorElement, PbBlockVariable, PbElement } from "~/types"; +import { Typography } from "@webiny/ui/Typography"; +import { + useSortableList, + useMoveVariable +} from "~/blockEditor/components/elementSettingsTab/variablesListHooks"; +import { Collapsable } from "~/editor/plugins/toolbar/navigator/StyledComponents"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { ReactComponent as DragIndicatorIcon } from "~/editor/plugins/toolbar/navigator/assets/drag_indicator_24px.svg"; +import { ReactComponent as DeleteIcon } from "~/editor/assets/icons/delete.svg"; +import { findElementByVariableId } from "~/blockEditor/config/eventActions/saveBlock"; +import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; + +const TitleWrapper = styled("div")({ + padding: "16px", + textAlign: "center" +}); + +const VariableItem = styled("div")({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "16px", + borderTop: "1px solid var(--mdc-theme-background)", + + "&:hover": { + backgroundColor: "var(--mdc-theme-background)", + color: "var(--mdc-theme-primary)", + + "&>div": { + display: "block" + } + }, + + "&>svg": { + cursor: "move" + } +}); + +const DeleteIconWrapper = styled("div")({ + display: "none", + marginLeft: "auto", + marginRight: "16px", + height: "24px", + cursor: "pointer", + + "&>svg": { + fill: "var(--mdc-theme-text-secondary-on-background)", + transition: "fill 0.2s" + }, + + "&:hover": { + "&>svg": { + fill: "var(--mdc-theme-text-primary-on-background)" + } + } +}); + +interface GetHighlightItemPropsParams { + dropItemAbove?: boolean; + isOver?: boolean; + elementType: string; +} + +const getHighlightItemProps = ({ + dropItemAbove, + isOver, + elementType +}: GetHighlightItemPropsParams) => { + if (!isOver || elementType !== "variable") { + return { + top: false, + bottom: false + }; + } + if (dropItemAbove) { + return { + top: true, + bottom: false + }; + } + return { + top: false, + bottom: true + }; +}; + +const VariablesListItem = ({ + variable, + index, + move, + onRemove +}: { + variable: PbBlockVariable; + index: number; + move: (current: number, next: number) => void; + onRemove: (variableId: string) => void; +}) => { + const { + ref: dragAndDropRef, + handlerId, + isOver, + dropItemAbove + } = useSortableList({ + move, + id: variable.id, + index, + type: "variable" + }); + + const highlightItem = getHighlightItemProps({ + isOver, + dropItemAbove, + elementType: "variable" + }); + + return ( + + + {variable?.label} + + onRemove(variable.id)} /> + + + + + ); +}; + +const VariablesList = ({ block }: { block: PbEditorElement }) => { + const { move } = useMoveVariable(block); + const updateElement = useUpdateElement(); + const { getElementTree } = useEventActionHandler(); + + const { showConfirmation } = useConfirmationDialog({ + title: "Remove variable", + message:

Are you sure you want to remove this variable?

+ }); + + const onRemove = useCallback( + (variableId: string) => { + showConfirmation(async () => { + if (block && block.id) { + // remove variable from block + const updatedVariables = block.data.variables.filter( + (variable: PbBlockVariable) => variable.id !== variableId + ); + updateElement({ + ...block, + data: { + ...block.data, + variables: updatedVariables + } + }); + + // check if there are more variables of this element + const isLastVariableOfElement = !updatedVariables.some( + (variable: PbBlockVariable) => + variable.id.split(".")[0] === variableId.split(".")[0] + ); + + // if there are not, then remove variableId from element + if (isLastVariableOfElement) { + const pbBlockElement = (await getElementTree()) as PbElement; + const element = findElementByVariableId( + pbBlockElement.elements, + variableId.split(".")[0] + ); + + // element "variableId" value should be dropped + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { variableId: elementVariableId, ...updatedElementData } = + element.data; + updateElement({ + ...element, + data: updatedElementData + }); + } + } + }); + }, + [block] + ); + + return ( + <> + + Block variables + + {block?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( + + ))} + + ); +}; + +export default VariablesList; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/variablesListHooks.ts b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/variablesListHooks.ts new file mode 100644 index 00000000000..7573c7133cd --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/variablesListHooks.ts @@ -0,0 +1,167 @@ +import { useRef, useState } from "react"; +import { useDrag, useDrop, DropTargetMonitor } from "react-dnd"; +import { DraggableItem } from "~/editor/components/Draggable"; +import { PbEditorElement } from "~/types"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; + +const moveInPlace = (arr: any[], from: number, to: number): any[] => { + const newArray = [...arr]; + const [item] = newArray.splice(from, 1); + newArray.splice(to, 0, item); + + return newArray; +}; + +interface UseMoveVariable { + move: (current: number, next: number) => void; +} + +export const useMoveVariable = (element: PbEditorElement): UseMoveVariable => { + const updateElement = useUpdateElement(); + const move = (current: number, next: number) => { + const reorderedVariables = moveInPlace(element?.data?.variables, current, next); + + updateElement({ + ...element, + data: { + ...element.data, + variables: reorderedVariables + } + }); + }; + + return { + move + }; +}; + +interface XYCoord { + x: number; + y: number; +} +interface DragItem { + index: number; + id: string; + type: string; +} +interface UseSortableListArgs { + index: number; + id: string; + type: string; + move: (current: number, next: number) => void; + beginDrag?: Function; + endDrag?: Function; +} + +export const useSortableList = ({ + index, + move, + id, + type, + beginDrag, + endDrag +}: UseSortableListArgs) => { + const ref = useRef(null); + const [dropItemAbove, setDropItemAbove] = useState(false); + const isDraggingDownwardsRef = useRef(false); + + const [{ handlerId, isOver }, drop] = useDrop({ + accept: "variable", + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + isOver: monitor.isOver() && monitor.isOver({ shallow: true }) + }; + }, + drop(item: DragItem) { + if (!ref.current) { + return; + } + const dragIndex = item.index; + const dropIndex = index; + const draggingDownwards = isDraggingDownwardsRef.current; + + // Calculate effective drop position. + let effectiveDropIndex; + if (draggingDownwards) { + effectiveDropIndex = dropItemAbove ? dropIndex - 1 : dropIndex; + } else { + effectiveDropIndex = dropItemAbove ? dropIndex : dropIndex + 1; + } + + // Don't replace items with themselves. + if (dragIndex === effectiveDropIndex) { + return; + } + + // Time to actually perform the action + move(dragIndex, effectiveDropIndex); + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + item.index = dropIndex; + }, + hover(item: DragItem, monitor: DropTargetMonitor) { + if (!ref.current) { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return; + } + + // Set dragging downwards + isDraggingDownwardsRef.current = dragIndex < hoverIndex; + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Perform the "drop above" move, only when the cursor is above 50% of the item's height. + const dropAbove = hoverClientY < hoverMiddleY; + setDropItemAbove(dropAbove); + } + }); + + const [{ isDragging }, drag, preview] = useDrag({ + item: { type, target: ["variable"], id, index, dragInNavigator: true } as DraggableItem, + collect: monitor => ({ + isDragging: monitor.isDragging() + }), + begin(monitor) { + if (typeof beginDrag === "function") { + return beginDrag(monitor); + } + }, + end(item, monitor) { + if (typeof endDrag === "function") { + return endDrag(item, monitor); + } + } + }); + + drag(drop(ref)); + + return { + ref, + isDragging, + handlerId, + drag, + drop, + preview, + isOver, + dropItemAbove + }; +}; diff --git a/packages/app-page-builder/src/blockEditor/config/BlockEditorConfig.tsx b/packages/app-page-builder/src/blockEditor/config/BlockEditorConfig.tsx index 49fccf1652a..dc6fb5aafab 100644 --- a/packages/app-page-builder/src/blockEditor/config/BlockEditorConfig.tsx +++ b/packages/app-page-builder/src/blockEditor/config/BlockEditorConfig.tsx @@ -1,7 +1,7 @@ import React from "react"; import { EventActionPlugins, EventActionHandlerPlugin } from "./eventActions"; import { EditorBarPlugins } from "./editorBar"; -import { BlockElementSidebarPlugin } from "./BlockElementSidebar"; +import { ElementSettingsTabContentPlugin } from "./ElementSettingsTabContentPlugin"; export const BlockEditorConfig = React.memo(() => { return ( @@ -9,7 +9,7 @@ export const BlockEditorConfig = React.memo(() => { - + ); }); diff --git a/packages/app-page-builder/src/blockEditor/config/BlockElementSidebar.tsx b/packages/app-page-builder/src/blockEditor/config/BlockElementSidebar.tsx deleted file mode 100644 index 45e7092cbf9..00000000000 --- a/packages/app-page-builder/src/blockEditor/config/BlockElementSidebar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { EditorSidebarTab } from "~/editor"; -import { createComponentPlugin } from "@webiny/app-admin"; -import { useActiveElement } from "~/editor/hooks/useActiveElement"; - -export const BlockElementSidebarPlugin = createComponentPlugin(EditorSidebarTab, Tab => { - return function ElementTab({ children, ...props }) { - const [element] = useActiveElement(); - - const isElementTab = props.label === "Element"; - - const shouldBeDisabled = element ? element.type === "block" && isElementTab : false; - const disabled = Boolean(shouldBeDisabled || props.disabled); - - return ( - - {disabled ? null : children} - - ); - }; -}); diff --git a/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx b/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx new file mode 100644 index 00000000000..be9d2df714f --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { plugins } from "@webiny/plugins"; +import { SidebarActions } from "~/editor"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import ElementNotLinked from "~/blockEditor/components/elementSettingsTab/ElementNotLinked"; +import VariableSettings from "~/blockEditor/components/elementSettingsTab/VariableSettings"; +import VariablesList from "~/blockEditor/components/elementSettingsTab/VariablesList"; +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export const ElementSettingsTabContentPlugin = createComponentPlugin( + SidebarActions, + SidebarActionsWrapper => { + const variablePlugins = plugins.byType( + "pb-block-editor-create-variable" + ); + + return function SettingsTabContent({ children, ...props }) { + const [element] = useActiveElement(); + const canHaveVariable = + element && + variablePlugins.some(variablePlugin => variablePlugin.elementType === element.type); + const hasVariable = element && element.data?.variableId; + const isBlock = element && element.type === "block"; + + return ( + <> + {isBlock ? ( + + ) : ( + {children} + )} + {canHaveVariable && !hasVariable && } + {canHaveVariable && hasVariable && } + + ); + }; + } +); diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BackButton/BackButton.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/BackButton/BackButton.tsx index 135ac2ec836..efe4a0017c4 100644 --- a/packages/app-page-builder/src/blockEditor/config/editorBar/BackButton/BackButton.tsx +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BackButton/BackButton.tsx @@ -24,7 +24,7 @@ export const BackButtonPlugin = createComponentPlugin(EditorBar.BackButton, () = console.error("Could not determine block ID from params."); return; } - history.push(`/page-builder/blocks?id=${id}`); + history.push(`/page-builder/page-blocks`); }} icon={} /> diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettings.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettings.tsx new file mode 100644 index 00000000000..65d1e608914 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettings.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { blockSettingsStateAtom } from "./state"; +import { useRecoilValue } from "recoil"; +import BlockSettingsModal from "./BlockSettingsModal"; + +/* For the time being, we're importing from the base editor, to not break things for existing users. */ +import { EditorBar } from "~/editor"; + +export const BlockSettingsOverlay = createComponentPlugin(EditorBar, EditorBar => { + return function BlockSettingsOverlay() { + const isActive = useRecoilValue(blockSettingsStateAtom); + + return ( + <> + + {isActive ? : null} + + ); + }; +}); diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsButton.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsButton.tsx new file mode 100644 index 00000000000..99508b53372 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsButton.tsx @@ -0,0 +1,30 @@ +import React, { useCallback } from "react"; +import { useRecoilState } from "recoil"; +import { IconButton } from "@webiny/ui/Button"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { ReactComponent as SettingsIcon } from "./settings.svg"; +import { blockSettingsStateAtom } from "./state"; +import { EditorBar } from "~/editor"; + +const BlockSettingsButton: React.FC = () => { + const [, setState] = useRecoilState(blockSettingsStateAtom); + const onClickHandler = useCallback(() => { + setState(true); + }, []); + + return } />; +}; + +export const AddBlockSettingsButton = createComponentPlugin( + EditorBar.RightSection, + RightSection => { + return function ComposeRightSection(props) { + return ( + + + {props.children} + + ); + }; + } +); diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsModal.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsModal.tsx new file mode 100644 index 00000000000..03354e51737 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/BlockSettingsModal.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { useRecoilState } from "recoil"; + +import { Form } from "@webiny/form"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { SimpleFormContent } from "@webiny/app-admin/components/SimpleForm"; +import { validation } from "@webiny/validation"; +import { Select } from "@webiny/ui/Select"; +import { Dialog, DialogCancel, DialogTitle, DialogActions, DialogContent } from "@webiny/ui/Dialog"; + +import { blockSettingsStateAtom } from "./state"; +import { useBlock } from "~/blockEditor/hooks/useBlock"; +import { useBlockCategories } from "~/blockEditor/hooks/useBlockCategories"; +import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; +import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; +import { BlockAtomType } from "~/blockEditor/state"; + +const ButtonWrapper = styled("div")({ + display: "flex", + justifyContent: "space-between", + width: "100%" +}); + +const BlockSettingsModal: React.FC = () => { + const handler = useEventActionHandler(); + const [block] = useBlock(); + const blockCategories = useBlockCategories(); + const [, setState] = useRecoilState(blockSettingsStateAtom); + const onClose = useCallback(() => { + setState(false); + }, []); + + const updateBlock = (data: Partial) => { + handler.trigger( + new UpdateDocumentActionEvent({ + history: false, + document: data + }) + ); + }; + + const onSubmit = useCallback(formData => { + updateBlock({ blockCategory: formData.blockCategory }); + onClose(); + }, []); + + return ( + +
+ {({ form, Bind }) => ( + <> + Block Settings + + + + + + + + + + Cancel + { + form.submit(ev); + }} + > + Save + + + + + )} +
+
+ ); +}; + +export default BlockSettingsModal; diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/index.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/index.tsx new file mode 100644 index 00000000000..97c24bb7a79 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { BlockSettingsOverlay } from "./BlockSettings"; +import { AddBlockSettingsButton } from "./BlockSettingsButton"; + +export const BlockSettingsPlugin = () => { + return ( + <> + + + + ); +}; diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/settings.svg b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/settings.svg new file mode 100644 index 00000000000..383b39df409 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/settings.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/state.ts b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/state.ts new file mode 100644 index 00000000000..0aa0e1029e0 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/BlockSettings/state.ts @@ -0,0 +1,8 @@ +import { atom } from "recoil"; + +export type BlockSettingsState = boolean; + +export const blockSettingsStateAtom = atom({ + key: "blockSettingsStateAtom", + default: false +}); diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/EditorBarPlugins.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/EditorBarPlugins.tsx index 54b59ff6ca2..f8a9b644f52 100644 --- a/packages/app-page-builder/src/blockEditor/config/editorBar/EditorBarPlugins.tsx +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/EditorBarPlugins.tsx @@ -1,5 +1,6 @@ import React from "react"; import { BackButtonPlugin } from "./BackButton"; +import { BlockSettingsPlugin } from "./BlockSettings"; import { SaveBlockButtonPlugin } from "./SaveBlockButton"; import { TitlePlugin } from "./Title"; @@ -8,6 +9,7 @@ export const EditorBarPlugins = () => { <> + ); diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/SaveBlockButton/SaveBlockButton.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/SaveBlockButton/SaveBlockButton.tsx index 91eca6d1a13..33615650c30 100644 --- a/packages/app-page-builder/src/blockEditor/config/editorBar/SaveBlockButton/SaveBlockButton.tsx +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/SaveBlockButton/SaveBlockButton.tsx @@ -5,8 +5,8 @@ import { useRouter } from "@webiny/react-router"; import { ButtonPrimary } from "@webiny/ui/Button"; import { EditorBar } from "~/editor"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; -import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; import { useBlock } from "~/blockEditor/hooks/useBlock"; +import { SaveBlockActionEvent } from "~/blockEditor/config/eventActions/saveBlock/event"; const DefaultSaveBlockButton: React.FC = () => { const [block] = useBlock(); @@ -16,21 +16,15 @@ const DefaultSaveBlockButton: React.FC = () => { const saveChanges = useCallback(() => { eventActionHandler.trigger( - new UpdateDocumentActionEvent({ + new SaveBlockActionEvent({ debounce: false, - onFinish() { - history.push( - `/page-builder/blocks?id=${encodeURIComponent(block.id as string)}` - ); - - // Let's wait a bit, because we are also redirecting the user. - setTimeout(() => { - showSnackbar("Your page was published successfully!"); - }, 500); + onFinish: () => { + history.push(`/page-builder/page-blocks`); + showSnackbar(`Block "${block.name}" saved successfully!`); } }) ); - }, [block.id]); + }, [block.name]); return Save Changes; }; diff --git a/packages/app-page-builder/src/blockEditor/config/editorBar/Title/Title.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/Title/Title.tsx index 5bd6cdce8a9..37fb17f05d6 100644 --- a/packages/app-page-builder/src/blockEditor/config/editorBar/Title/Title.tsx +++ b/packages/app-page-builder/src/blockEditor/config/editorBar/Title/Title.tsx @@ -22,7 +22,7 @@ const Title: React.FC = () => { const { showSnackbar } = useSnackbar(); const [editTitle, setEdit] = useState(false); const [stateTitle, setTitle] = useState(null); - let title = stateTitle === null ? block.title : stateTitle; + let title = stateTitle === null ? block.name : stateTitle; const updateBlock = (data: Partial) => { handler.trigger( @@ -44,7 +44,7 @@ const Title: React.FC = () => { setTitle(title); } setEdit(false); - updateBlock({ title }); + updateBlock({ name: title }); }, [title]); const onKeyDown = useCallback( @@ -54,7 +54,7 @@ const Title: React.FC = () => { case "Escape": e.preventDefault(); setEdit(false); - setTitle(block.title || ""); + setTitle(block.name || ""); break; case "Enter": if (title === "") { @@ -65,13 +65,13 @@ const Title: React.FC = () => { e.preventDefault(); setEdit(false); - updateBlock({ title }); + updateBlock({ name: title }); break; default: return; } }, - [title, block.title] + [title, block.name] ); // Disable autoFocus because for some reason, blur event would automatically be triggered when clicking diff --git a/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/getPreviewImage.ts b/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/getPreviewImage.ts new file mode 100644 index 00000000000..3c8982f45aa --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/getPreviewImage.ts @@ -0,0 +1,73 @@ +/** + * Package dataurl-to-blob does not have types. + */ +// @ts-ignore +import dataURLtoBlob from "dataurl-to-blob"; +import get from "lodash/get"; +import { plugins } from "@webiny/plugins"; +import { FileUploaderPlugin } from "@webiny/app/types"; +import domToImage from "~/editor/plugins/elementSettings/save/SaveDialog/domToImage"; +import { CREATE_FILE } from "~/editor/plugins/elementSettings/save/SaveDialog/graphql"; + +interface ImageDimensionsType { + width: number; + height: number; +} +function getDataURLImageDimensions(dataURL: string): Promise { + return new Promise(resolve => { + const image = new window.Image(); + image.onload = function () { + resolve({ width: image.width, height: image.height }); + }; + image.src = dataURL; + }); +} + +interface createdImageType { + data: any; +} +export default async function getPreviewImage(element: any, meta: any): Promise { + const node = document.getElementById(element.id); + + if (!node) { + return { + data: {} + }; + } + + const editor = document.querySelector(".pb-editor"); + // Hide element highlight while creating the image + editor && editor.classList.add("pb-editor-no-highlight"); + + const dataUrl = await domToImage.toPng(node, { + width: 1000 + }); + + editor && editor.classList.remove("pb-editor-no-highlight"); + + const imageMeta = await getDataURLImageDimensions(dataUrl); + const blob = dataURLtoBlob(dataUrl); + blob.name = "pb-editor-page-element-" + element.id + ".png"; + + const fileUploaderPlugin = plugins.byName("app-file-manager-storage"); + /** + * We break the method because it would break if there is no fileUploaderPlugin. + */ + if (!fileUploaderPlugin) { + return { + data: {} + }; + } + const previewImage = await fileUploaderPlugin.upload(blob, { apolloClient: meta.client }); + previewImage.meta = imageMeta; + previewImage.meta.private = true; + + const createdImageResponse = await meta.client.mutate({ + mutation: CREATE_FILE, + variables: { + data: previewImage + } + }); + + return get(createdImageResponse, "data.fileManager.createFile", {}); +} diff --git a/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts b/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts index f66af5942df..cf7a8d11e75 100644 --- a/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts +++ b/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts @@ -1,12 +1,59 @@ // import gql from "graphql-tag"; import lodashDebounce from "lodash/debounce"; +import { plugins } from "@webiny/plugins"; import { SaveBlockActionArgsType } from "./types"; import { ToggleSaveBlockStateActionEvent } from "./event"; import { BlockEventActionCallable } from "~/blockEditor/types"; import { BlockWithContent } from "~/blockEditor/state"; +import { UPDATE_PAGE_BLOCK } from "~/admin/views/PageBlocks/graphql"; +import getPreviewImage from "./getPreviewImage"; +import { removeElementId } from "~/editor/helpers"; +import { PbElement, PbBlockVariable, PbBlockEditorCreateVariablePlugin } from "~/types"; + +export const findElementByVariableId = (elements: PbElement[], variableId: string): any => { + for (const element of elements) { + if (element.data?.variableId === variableId) { + return element; + } + if (element.elements?.length > 0) { + const found = findElementByVariableId(element.elements, variableId); + if (found) { + return found; + } + } + } +}; + +const syncBlockVariables = (block: PbElement) => { + const createVariablePlugins = plugins.byType( + "pb-block-editor-create-variable" + ); + + const syncedVariables = block.data?.variables?.reduce(function ( + result: Array, + variable: PbBlockVariable + ) { + const element = findElementByVariableId(block.elements, variable.id.split(".")[0]); + const createVariablePlugin = createVariablePlugins.find( + plugin => plugin.elementType === element?.type + ); + + if (createVariablePlugin) { + result.push({ + ...variable, + value: createVariablePlugin.getVariableValue({ element, variableId: variable.id }) + }); + } + + return result; + }, + []); + + return { ...block, data: { ...block.data, variables: syncedVariables } }; +}; // TODO: add more properties here -type BlockType = Pick; +type BlockType = Pick; const triggerOnFinish = (args?: SaveBlockActionArgsType): void => { if (!args || !args.onFinish || typeof args.onFinish !== "function") { @@ -24,33 +71,18 @@ export const saveBlockAction: BlockEventActionCallable ) => { // TODO: make sure the API call is not sent if the data was not changed since the last invocation of this event. // See `pageEditor` for an example and feel free to copy that same logic over here. + const element = (await state.getElementTree()) as PbElement; + // We need to grab the first block from the "document" element. + const createdImage = await getPreviewImage(element.elements[0], meta); const data: BlockType = { - title: state.block.title, - content: await state.getElementTree() + name: state.block.name, + blockCategory: state.block.blockCategory, + // We need to grab the contents of the "document" element, and we can safely just grab the first element + // because we only have 1 block in the block editor. + content: removeElementId(syncBlockVariables(element.elements[0])) }; - // const updateBlock = gql` - // mutation updateBlock($id: ID!, $data: PbUpdateBlockInput!) { - // pageBuilder { - // updateBlock(id: $id, data: $data) { - // data { - // id - // content - // title - // status - // savedOn - // } - // error { - // code - // message - // data - // } - // } - // } - // } - // `; - if (debouncedSave) { debouncedSave.cancel(); } @@ -58,20 +90,24 @@ export const saveBlockAction: BlockEventActionCallable const runSave = async () => { meta.eventActionHandler.trigger(new ToggleSaveBlockStateActionEvent({ saving: true })); - // await meta.client.mutate({ - // mutation: updateBlock, - // variables: { - // id: state.block.id, - // data - // } - // }); + await meta.client.mutate({ + mutation: UPDATE_PAGE_BLOCK, + variables: { + id: state.block.id, + data: { + ...data, + preview: createdImage.data + } + } + }); await new Promise(resolve => { - console.log("Saving block", data); setTimeout(resolve, 500); }); - meta.eventActionHandler.trigger(new ToggleSaveBlockStateActionEvent({ saving: false })); + await meta.eventActionHandler.trigger( + new ToggleSaveBlockStateActionEvent({ saving: false }) + ); triggerOnFinish(args); }; diff --git a/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts b/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts index df951cb5cba..198712040eb 100644 --- a/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts +++ b/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts @@ -1,12 +1,10 @@ import { UpdateDocumentActionArgsType } from "~/editor/recoil/actions"; -import { SaveBlockActionEvent } from "./saveBlock"; import { BlockAtomType } from "~/blockEditor/state"; import { BlockEventActionCallable } from "~/blockEditor/types"; export const updateBlockAction: BlockEventActionCallable< UpdateDocumentActionArgsType -> = (state, _, args) => { - console.log("updateBlockAction", state, args); +> = async (state, _, args) => { return { state: { block: { @@ -14,11 +12,6 @@ export const updateBlockAction: BlockEventActionCallable< ...(args?.document || {}) } }, - actions: [ - new SaveBlockActionEvent({ - debounce: args?.debounce || false, - onFinish: args?.onFinish - }) - ] + actions: [] }; }; diff --git a/packages/app-page-builder/src/blockEditor/hooks/useBlockCategories.ts b/packages/app-page-builder/src/blockEditor/hooks/useBlockCategories.ts new file mode 100644 index 00000000000..33e368527b0 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/hooks/useBlockCategories.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@apollo/react-hooks"; +import get from "lodash/get"; + +import { LIST_BLOCK_CATEGORIES } from "~/admin/views/BlockCategories/graphql"; +import { PbBlockCategory } from "~/types"; + +export function useBlockCategories() { + const { data } = useQuery(LIST_BLOCK_CATEGORIES); + const blockCategoriesData: PbBlockCategory[] = + get(data, "pageBuilder.listBlockCategories.data") || []; + + return blockCategoriesData; +} diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts new file mode 100644 index 00000000000..e71374450d6 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts @@ -0,0 +1,50 @@ +import React, { useCallback } from "react"; +import { plugins } from "@webiny/plugins"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useCurrentBlockElement } from "~/editor/hooks/useCurrentBlockElement"; +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +interface CreateVariableActionPropsType { + children: React.ReactElement; +} + +const CreateVariableAction: React.FC = ({ children }) => { + const [element] = useActiveElement(); + const { block } = useCurrentBlockElement(); + const updateElement = useUpdateElement(); + + const onClick = useCallback((): void => { + if (element && block) { + const createVariablePlugins = plugins.byType( + "pb-block-editor-create-variable" + ); + const variablePlugin = createVariablePlugins.find( + plugin => plugin.elementType === element.type + ); + + if (!variablePlugin) { + return; + } + + updateElement({ + ...element, + data: { ...element.data, variableId: element.id } + }); + updateElement({ + ...block, + data: { + ...block.data, + variables: [ + ...(block.data?.variables || []), + ...variablePlugin.createVariables({ element }) + ] + } + }); + } + }, [element, block, updateElement]); + + return React.cloneElement(children, { onClick }); +}; + +export default React.memo(CreateVariableAction); diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/button/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/button/index.ts new file mode 100644 index 00000000000..20a69a7b74d --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/button/index.ts @@ -0,0 +1,36 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-button", + type: "pb-block-editor-create-variable", + elementType: "button", + createVariables({ element }) { + return [ + { + id: `${element.id}.label`, + type: "button", + label: "Button label", + value: element.data?.buttonText + }, + { + id: `${element.id}.url`, + type: "button", + label: "Button URL", + value: element.data?.action?.href + } + ]; + }, + getVariableValue({ element, variableId }) { + if (!variableId) { + return null; + } + if (variableId.endsWith(".label")) { + return element.data?.buttonText; + } + if (variableId.endsWith(".url")) { + return element.data?.action?.href; + } + + return null; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/heading/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/heading/index.ts new file mode 100644 index 00000000000..e44369ab8fa --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/heading/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-heading", + type: "pb-block-editor-create-variable", + elementType: "heading", + createVariables({ element }) { + return [ + { + id: element.id, + type: "heading", + label: "Heading text", + value: element.data?.text?.data?.text + } + ]; + }, + getVariableValue({ element }) { + return element.data?.text?.data?.text; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/image/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/image/index.ts new file mode 100644 index 00000000000..538def03d6f --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/image/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-image", + type: "pb-block-editor-create-variable", + elementType: "image", + createVariables({ element }) { + return [ + { + id: element.id, + type: "image", + label: "Image", + value: element.data?.image?.file + } + ]; + }, + getVariableValue({ element }) { + return element.data?.image?.file; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/imagesList/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/imagesList/index.ts new file mode 100644 index 00000000000..2fabdb308aa --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/imagesList/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-images-list", + type: "pb-block-editor-create-variable", + elementType: "images-list", + createVariables({ element }) { + return [ + { + id: element.id, + type: "images-list", + label: "Images list", + value: element.data?.images + } + ]; + }, + getVariableValue({ element }) { + return element.data?.images; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/index.ts new file mode 100644 index 00000000000..3c16fc3b502 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/index.ts @@ -0,0 +1,9 @@ +import button from "./button"; +import heading from "./heading"; +import image from "./image"; +import imagesList from "./imagesList"; +import list from "./list"; +import paragraph from "./paragraph"; +import quote from "./quote"; + +export default () => [button, heading, image, imagesList, list, paragraph, quote]; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/list/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/list/index.ts new file mode 100644 index 00000000000..c05e8beb507 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/list/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-list", + type: "pb-block-editor-create-variable", + elementType: "list", + createVariables({ element }) { + return [ + { + id: element.id, + type: "list", + label: "List", + value: element.data?.text?.data?.text + } + ]; + }, + getVariableValue({ element }) { + return element.data?.text?.data?.text; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/paragraph/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/paragraph/index.ts new file mode 100644 index 00000000000..695644f3f4b --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/paragraph/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-paragraph", + type: "pb-block-editor-create-variable", + elementType: "paragraph", + createVariables({ element }) { + return [ + { + id: element.id, + type: "paragraph", + label: "Paragraph text", + value: element.data?.text?.data?.text + } + ]; + }, + getVariableValue({ element }) { + return element.data?.text?.data?.text; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementVariables/quote/index.ts b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/quote/index.ts new file mode 100644 index 00000000000..23cc2c315dc --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementVariables/quote/index.ts @@ -0,0 +1,20 @@ +import { PbBlockEditorCreateVariablePlugin } from "~/types"; + +export default { + name: "pb-block-editor-create-variable-quote", + type: "pb-block-editor-create-variable", + elementType: "quote", + createVariables({ element }) { + return [ + { + id: element.id, + type: "quote", + label: "Quote", + value: element.data?.text?.data?.text + } + ]; + }, + getVariableValue({ element }) { + return element.data?.text?.data?.text; + } +} as PbBlockEditorCreateVariablePlugin; diff --git a/packages/app-page-builder/src/blockEditor/state/blockAtom.ts b/packages/app-page-builder/src/blockEditor/state/blockAtom.ts index 55602c3674c..95c3c0834ff 100644 --- a/packages/app-page-builder/src/blockEditor/state/blockAtom.ts +++ b/packages/app-page-builder/src/blockEditor/state/blockAtom.ts @@ -7,7 +7,8 @@ export interface BlockWithContent extends BlockAtomType { export interface BlockAtomType { id: string; - title?: string; + name?: string; + blockCategory?: string; savedOn?: Date; createdBy: { id: string | null; diff --git a/packages/app-page-builder/src/editor/components/DropZone.tsx b/packages/app-page-builder/src/editor/components/DropZone.tsx index 3958579d64d..611f6319dea 100644 --- a/packages/app-page-builder/src/editor/components/DropZone.tsx +++ b/packages/app-page-builder/src/editor/components/DropZone.tsx @@ -2,19 +2,20 @@ import React from "react"; import { Center } from "./DropZone/Center"; import { Horizontal, HorizontalPropsType } from "./DropZone/Horizontal"; import { Vertical, VerticalPropsType } from "./DropZone/Vertical"; +import { makeComposable } from "@webiny/react-composition"; export default { - Above(props: HorizontalPropsType) { + Above: makeComposable("Dropzone.Above", (props: HorizontalPropsType) => { return ; - }, - Below(props: HorizontalPropsType) { + }), + Below: makeComposable("Dropzone.Below", (props: HorizontalPropsType) => { return ; - }, - Left(props: VerticalPropsType) { + }), + Left: makeComposable("Dropzone.Left", (props: VerticalPropsType) => { return ; - }, - Right(props: VerticalPropsType) { + }), + Right: makeComposable("Dropzone.Right", (props: VerticalPropsType) => { return ; - }, - Center + }), + Center: makeComposable("Dropzone.Center", Center) }; diff --git a/packages/app-page-builder/src/editor/components/Editor/Sidebar/ElementSettingsTabContent.tsx b/packages/app-page-builder/src/editor/components/Editor/Sidebar/ElementSettingsTabContent.tsx index fe19025f860..b6edd349589 100644 --- a/packages/app-page-builder/src/editor/components/Editor/Sidebar/ElementSettingsTabContent.tsx +++ b/packages/app-page-builder/src/editor/components/Editor/Sidebar/ElementSettingsTabContent.tsx @@ -1,5 +1,6 @@ import React from "react"; import styled from "@emotion/styled"; +import { makeComposable } from "@webiny/app-admin"; import NoActiveElement from "./NoActiveElement"; import { ReactComponent as TouchIcon } from "./icons/touch_app.svg"; import { useActiveElement } from "~/editor/hooks/useActiveElement"; @@ -47,7 +48,7 @@ const ElementSettingsTabContent: React.FC = () => { return ( - + {elementSettings.map(({ plugin, options }, index) => { return (
@@ -56,10 +57,17 @@ const ElementSettingsTabContent: React.FC = () => {
); })} -
+
); }; +export const SidebarActions = makeComposable( + "ElementSettingsTabContent", + ({ children, ...props }) => { + return {children}; + } +); + export default React.memo(ElementSettingsTabContent); diff --git a/packages/app-page-builder/src/editor/components/Element.tsx b/packages/app-page-builder/src/editor/components/Element.tsx index 34058d555f5..8d7dfe3905e 100644 --- a/packages/app-page-builder/src/editor/components/Element.tsx +++ b/packages/app-page-builder/src/editor/components/Element.tsx @@ -1,18 +1,11 @@ import React, { useCallback } from "react"; -import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; import { Transition } from "react-transition-group"; import { plugins } from "@webiny/plugins"; import { renderPlugins } from "@webiny/app/plugins"; import { PbEditorPageElementPlugin, PbEditorElement } from "~/types"; import Draggable from "./Draggable"; import tryRenderingPlugin from "../../utils/tryRenderingPlugin"; -import { - disableDraggingMutation, - elementByIdSelector, - enableDraggingMutation, - uiAtom, - activeElementAtom -} from "../recoil/modules"; +import { disableDraggingMutation, enableDraggingMutation } from "../recoil/modules"; import { defaultStyle, ElementContainer, @@ -20,6 +13,11 @@ import { typeStyle } from "./Element/ElementStyled"; import { DragElementWrapper, DragSourceOptions } from "react-dnd"; +import { useActiveElementId } from "~/editor/hooks/useActiveElementId"; +import { useUI } from "~/editor/hooks/useUI"; +import { useElementById } from "~/editor/hooks/useElementById"; +import { SetterOrUpdater } from "recoil"; +import { ElementProvider } from "~/editor/contexts/ElementProvider"; interface RenderDraggableCallableParams { drag: DragElementWrapper | null; @@ -49,12 +47,13 @@ const ElementComponent: React.FC = ({ className = "", isActive }) => { - const elementAtomState = useRecoilState(elementByIdSelector(elementId)); - const element = elementAtomState[0] as PbEditorElement; - const setElementAtomValue = elementAtomState[1]; - const setUiAtomValue = useSetRecoilState(uiAtom); - const setActiveElementAtomValue = useSetRecoilState(activeElementAtom); - + // Type casting here because we're (almost) confident that there _is_ an element with this ID. + const [element, updateElement] = useElementById(elementId) as [ + PbEditorElement, + SetterOrUpdater + ]; + const [, setUi] = useUI(); + const [, setActiveElementId] = useActiveElementId(); const { isHighlighted } = element as PbEditorElement; const plugin = getElementPlugin(element); @@ -62,7 +61,7 @@ const ElementComponent: React.FC = ({ const beginDrag = useCallback(() => { const data = { id: element.id, type: element.type }; setTimeout(() => { - setUiAtomValue(enableDraggingMutation); + setUi(enableDraggingMutation); }); const target = plugin ? plugin.target : null; return { @@ -72,14 +71,14 @@ const ElementComponent: React.FC = ({ }, [elementId]); const endDrag = useCallback(() => { - setUiAtomValue(disableDraggingMutation); + setUi(disableDraggingMutation); }, [elementId]); const onClick = useCallback((): void => { if (!element || element.type === "document" || isActive) { return; } - setActiveElementAtomValue(elementId); + setActiveElementId(elementId); }, [elementId, isActive]); const onMouseOver = useCallback( @@ -91,7 +90,7 @@ const ElementComponent: React.FC = ({ if (isHighlighted) { return; } - setElementAtomValue({ isHighlighted: true } as any); + updateElement(element => ({ ...element, isHighlighted: true })); }, [elementId] ); @@ -99,7 +98,7 @@ const ElementComponent: React.FC = ({ if (!element || element.type === "document") { return; } - setElementAtomValue({ isHighlighted: false } as any); + updateElement(element => ({ ...element, isHighlighted: false })); }, [elementId]); const renderDraggable: RenderDraggableCallable = ({ drag }) => { @@ -129,40 +128,42 @@ const ElementComponent: React.FC = ({ const isDraggable = Array.isArray(plugin.target) && plugin.target.length > 0; return ( - - {state => ( - -
c).join(" ")}> - - {renderDraggable} - - - {renderedPlugin} -
-
- )} -
+ + + {state => ( + +
c).join(" ")}> + + {renderDraggable} + + + {renderedPlugin} +
+
+ )} +
+
); }; const withHighlightElement = (Component: React.FC) => { return function withHighlightElementComponent(props: ElementPropsType) { - const activeElementAtomValue = useRecoilValue(activeElementAtom); + const [activeElementId] = useActiveElementId(); - return ; + return ; }; }; diff --git a/packages/app-page-builder/src/editor/components/IconPicker.tsx b/packages/app-page-builder/src/editor/components/IconPicker.tsx index 3f03212f43f..98799f4f01c 100644 --- a/packages/app-page-builder/src/editor/components/IconPicker.tsx +++ b/packages/app-page-builder/src/editor/components/IconPicker.tsx @@ -4,7 +4,7 @@ import { plugins } from "@webiny/plugins"; import { Typography } from "@webiny/ui/Typography"; import { Grid } from "react-virtualized"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { DelayedOnChange } from "./DelayedOnChange"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { Menu } from "@webiny/ui/Menu"; import { Input } from "@webiny/ui/Input"; import { PbIcon, PbIconsPlugin } from "../../types"; diff --git a/packages/app-page-builder/src/editor/components/MediumEditor/index.ts b/packages/app-page-builder/src/editor/components/MediumEditor/index.ts index cbe8a49ba58..d01910a3bd6 100644 --- a/packages/app-page-builder/src/editor/components/MediumEditor/index.ts +++ b/packages/app-page-builder/src/editor/components/MediumEditor/index.ts @@ -1,4 +1,4 @@ -import React, { useEffect, createElement } from "react"; +import React, { useEffect, createElement, useCallback } from "react"; import MediumEditor, { CoreOptions } from "medium-editor"; import { css } from "emotion"; import merge from "lodash/merge"; @@ -14,7 +14,7 @@ const editorClass = css({ interface ReactMediumEditorProps { value: string; onChange: (value: string) => void; - onSelect: () => void; + onSelect: (value: string) => void; tag: string | [string, Record]; options?: CoreOptions; [key: string]: any; @@ -30,19 +30,25 @@ const ReactMediumEditor: React.FC = ({ const elementRef = React.useRef(null); const editorRef = React.useRef(); - const handleChange = (_: any, editable: HTMLElement): void => { - if (typeof onChange !== "function") { - return; - } - onChange(editable.innerHTML); - }; + const handleChange = useCallback( + (_: any, editable: HTMLElement): void => { + if (typeof onChange !== "function") { + return; + } + onChange(editable.innerHTML); + }, + [onChange] + ); - const handleSelect = (): void => { - if (typeof onSelect !== "function") { - return; - } - onSelect(); - }; + const handleSelect = useCallback( + (_: any, editable: HTMLElement): void => { + if (typeof onSelect !== "function") { + return; + } + onSelect(editable.innerHTML); + }, + [onSelect] + ); const tagName = Array.isArray(tag) ? tag[0] : tag; const tagProps = Array.isArray(tag) && tag[1] ? tag[1] : {}; @@ -73,6 +79,21 @@ const ReactMediumEditor: React.FC = ({ // Create "MediumEditor" instance editorRef.current = new MediumEditor(elementRef.current, mediumEditorOptions); + return () => { + if (!editorRef.current) { + return; + } + editorRef.current.destroy(); + }; + }, [options, tagName]); + + // We need to resubscribe "blur" and "editableInput" to use + // up-to-date versions of onChange and onSelect functions + useEffect(() => { + if (!editorRef || !editorRef.current) { + return; + } + editorRef.current.subscribe("blur", handleChange); editorRef.current.subscribe("editableInput", handleSelect); @@ -80,9 +101,10 @@ const ReactMediumEditor: React.FC = ({ if (!editorRef.current) { return; } - editorRef.current.destroy(); + editorRef.current.unsubscribe("blur", handleChange); + editorRef.current.unsubscribe("editableInput", handleSelect); }; - }, [options, tagName]); + }, [handleChange, handleSelect, tagName]); return createElement(tagName, { dangerouslySetInnerHTML: { __html: value }, diff --git a/packages/app-page-builder/src/editor/components/Text/PbText.tsx b/packages/app-page-builder/src/editor/components/Text/PbText.tsx index 30a91658234..3ef35c54286 100644 --- a/packages/app-page-builder/src/editor/components/Text/PbText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PbText.tsx @@ -9,6 +9,7 @@ import { ElementRoot } from "~/render/components/ElementRoot"; import useUpdateHandlers from "../../plugins/elementSettings/useUpdateHandlers"; import ReactMediumEditor from "../../components/MediumEditor"; import { applyFallbackDisplayMode } from "../../plugins/elementSettings/elementSettingsUtils"; +import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; export const textClassName = "webiny-pb-base-page-element-style webiny-pb-page-element-text"; const DATA_NAMESPACE = "data.text"; @@ -20,6 +21,7 @@ interface TextElementProps { } const PbText: React.FC = ({ elementId, mediumEditorOptions, rootClassName }) => { const element = useRecoilValue(elementWithChildrenByIdSelector(elementId)); + const variableValue = useElementVariableValue(element); const [{ displayMode }] = useRecoilState(uiAtom); const [activeElementId, setActiveElementAtomValue] = useRecoilState(activeElementAtom); const { getUpdateValue } = useUpdateHandlers({ @@ -36,6 +38,11 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro [displayMode] ); + const initialText = useMemo( + () => variableValue || get(element, `${DATA_NAMESPACE}.data.text`), + [variableValue, element] + ); + const value = get(element, `${DATA_NAMESPACE}.${displayMode}`, fallbackValue); const onChange = useCallback( @@ -57,7 +64,6 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro return null; } - const textContent = get(element, `${DATA_NAMESPACE}.data.text`); const tag = get(value, "tag"); const typography = get(value, "typography"); @@ -69,7 +75,7 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro (undefined); + +export interface ElementProviderProps { + element: PbEditorElement; +} + +export const ElementProvider: React.FC = ({ element, children }) => { + return {children}; +}; diff --git a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx index 82604ed3831..e8450370887 100644 --- a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx @@ -39,10 +39,11 @@ import { PbEditorElement, EventActionHandler, EventActionHandlerTarget, - EventActionHandlerCallableState + EventActionHandlerCallableState, + GetElementTreeProps } from "~/types"; -import { composeSync, SyncProcessor } from "@webiny/utils/compose"; -import { UpdateElementTreeActionEvent } from "~/editor/recoil/actions"; +import { composeAsync, composeSync, AsyncProcessor, SyncProcessor } from "@webiny/utils/compose"; +import { UpdateElementTreeActionEvent, UpdateDocumentActionEvent } from "~/editor/recoil/actions"; type ListType = Map; type RegistryType = Map; @@ -87,6 +88,7 @@ const isTrackedAtomChanged = (state: Partial): boolean => { return false; }; +export type GetElementTree = AsyncProcessor; export type GetCallableState = SyncProcessor>; export type SaveCallableResults> = SyncProcessor<{ state: TState & Partial; @@ -94,6 +96,7 @@ export type SaveCallableResults> = SyncProcessor<{ }>; export interface EventActionHandlerProviderProps { + getElementTree?: Array; getCallableState?: Array; saveCallablesResults?: Array>; } @@ -133,7 +136,7 @@ export const EventActionHandlerProvider = makeComposable< pluginsAtomValueRef.current = pluginsAtomValue; uiAtomValueRef.current = uiAtomValue; snapshotRef.current = snapshot; - }, [sidebarAtomValue, rootElementAtomValue, pluginsAtomValue, uiAtomValue]); + }, [sidebarAtomValue, rootElementAtomValue, pluginsAtomValue, uiAtomValue, snapshot]); const registry = useRef(new Map()); @@ -147,6 +150,14 @@ export const EventActionHandlerProvider = makeComposable< }, 200); }; + const updateDocument = () => { + setTimeout(() => { + eventActionHandlerRef.current!.trigger( + new UpdateDocumentActionEvent({ history: false, debounce: true }) + ); + }, 200); + }; + const updateElements = useRecoilCallback(({ set }) => (elements: PbEditorElement[] = []) => { elements.forEach(item => { set(elementsAtom(item.id), prevValue => { @@ -170,33 +181,43 @@ export const EventActionHandlerProvider = makeComposable< return snapshot; }); - const getElementTree = async ( - element?: PbEditorElement, - path: string[] = [] - ): Promise => { - if (!element) { - element = (await getElementById(rootElementAtomValue)) as PbEditorElement; - } - if (element.parent) { - path.push(element.parent); - } - return { - id: element.id, - type: element.type, - data: element.data, - elements: await Promise.all( - /** - * We are positive that element.elements is array of strings. - */ - (element.elements as string[]).map(async child => { - return getElementTree((await getElementById(child)) as PbEditorElement, [ - ...path - ]); - }) - ), - path - }; - }; + const defaultGetElementTree = useCallback( + () => + async function getChildElement(props: GetElementTreeProps): Promise { + let element = props?.element; + if (!element) { + element = (await getElementById(rootElementAtomValue)) as PbEditorElement; + } + + const path = props?.path || []; + if (element.parent) { + path.push(element.parent); + } + + return { + id: element.id, + type: element.type, + data: element.data, + elements: await Promise.all( + /** + * We are positive that element.elements is array of strings. + */ + (element.elements as string[]).map(async child => { + return await getChildElement({ + element: (await getElementById(child)) as PbEditorElement, + path: [...path] + }); + }) + ), + path + }; + }, + [] + ); + + const getElementTree = useMemo(() => { + return composeAsync([...(props.getElementTree || []), defaultGetElementTree]); + }, [props.getElementTree]); const get = (name: string): ListType => { const list = registry.current.get(name); @@ -362,6 +383,7 @@ export const EventActionHandlerProvider = makeComposable< goToSnapshot(previousSnapshot); snapshotsHistory.current.busy = false; updateElementTree(); + updateDocument(); }, redo: () => { if (snapshotsHistory.current.busy === true) { @@ -381,6 +403,7 @@ export const EventActionHandlerProvider = makeComposable< goToSnapshot(nextSnapshot); snapshotsHistory.current.busy = false; updateElementTree(); + updateDocument(); }, startBatch: () => { snapshotsHistory.current.isBatching = true; diff --git a/packages/app-page-builder/src/editor/helpers.ts b/packages/app-page-builder/src/editor/helpers.ts index 95f17ff5616..093a3a34199 100644 --- a/packages/app-page-builder/src/editor/helpers.ts +++ b/packages/app-page-builder/src/editor/helpers.ts @@ -8,7 +8,8 @@ import { PbEditorPageElementPlugin, PbEditorPageElementSettingsPlugin, PbEditorPageElementStyleSettingsPlugin, - PbEditorElement + PbEditorElement, + PbElement } from "~/types"; const ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -126,9 +127,9 @@ export const createDroppedElement = ( return createElement(source.type, {}, target); }; /** - * Add unique id to elements recur + * Add unique id to elements recursively */ -const addElementId = (target: Omit): PbEditorElement => { +export const addElementId = (target: Omit): PbEditorElement => { /** * Need to cast because typescript thinks we removed everything via Omit??? */ @@ -146,6 +147,25 @@ const addElementId = (target: Omit): PbEditorElement => { } return element; }; +/** + * Remove id from elements recursively + */ +export const removeElementId = (el: PbElement): PbElement => { + // @ts-ignore + delete el.id; + + el.elements = el.elements.map(el => { + // @ts-ignore + delete el.id; + if (el.elements && el.elements.length) { + el = removeElementId(el); + } + + return el; + }); + + return el; +}; export const createBlockElements = (name: string): PbEditorElement => { const plugin = plugins.byName(name); diff --git a/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx b/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx new file mode 100644 index 00000000000..0860eb808a9 --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx @@ -0,0 +1,70 @@ +import { PbEditorElement } from "~/types"; +import { useCurrentElement } from "~/editor/hooks/useCurrentElement"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { elementByIdSelector } from "~/editor/recoil/modules"; +import { selectorFamily, useRecoilValue } from "recoil"; + +export interface UseCurrentBlock { + block: PbEditorElement | null; +} + +/** + * This selector will traverse the "elements" atom, going up the tree, until it finds the root block element. + */ +export const blockByElementSelector = selectorFamily({ + key: "blockByElementSelector", + get: id => { + return ({ get }) => { + if (!id) { + return null; + } + + let element = get(elementByIdSelector(id)); + + if (!element) { + return null; + } + + if (element.type === "block") { + return element; + } + + while (true) { + element = get(elementByIdSelector(element.parent || "")); + + if (!element) { + return null; + } + + if (element.type === "block") { + return element; + } + } + }; + } +}); + +/** + * There are several scenarios that need handling: + * 1) hook is called from editor content element renderer (any element state). + * 2) hook is called while one of the elements is active/selected (settings, toolbar, ....). + * 3) hook is called outside of editor content, and no element is selected -> return null. + */ +export function useCurrentBlockElement(): UseCurrentBlock { + let currentElementContext; + + // 1) hook is called from within the editor content component. + try { + currentElementContext = useCurrentElement(); + } catch (e) { + // Means we're not in the editor content. + } + + // 2) hook is called while one of the elements is active/selected (settings, toolbar, ....) + const [activeElement] = useActiveElement(); + + // Elements within the main editor content have higher priority when deciding the relevant element. + const elementId = currentElementContext?.element?.id || activeElement?.id; + + return { block: useRecoilValue(blockByElementSelector(elementId)) }; +} diff --git a/packages/app-page-builder/src/editor/hooks/useCurrentElement.tsx b/packages/app-page-builder/src/editor/hooks/useCurrentElement.tsx new file mode 100644 index 00000000000..1dd6b984e11 --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useCurrentElement.tsx @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { ElementContext } from "~/editor/contexts/ElementProvider"; +import { PbEditorElement } from "~/types"; + +export interface UseCurrentElement { + element: PbEditorElement; +} + +export function useCurrentElement(): UseCurrentElement { + const context = useContext(ElementContext); + if (!context) { + throw Error(`"useCurrentElement" must be used within an !`); + } + + return { element: context.element }; +} diff --git a/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts new file mode 100644 index 00000000000..b4d240a0469 --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { plugins } from "@webiny/plugins"; +import { + PbBlockVariable, + PbEditorElement, + PbEditorPageElementVariableRendererPlugin +} from "~/types"; +import { useParentBlock } from "~/editor/hooks/useParentBlock"; + +export function useElementVariables(element: PbEditorElement | null) { + const block = useParentBlock(element?.id); + + const variableValue = useMemo(() => { + if (element?.data?.variableId) { + const variable = block?.data?.variables?.filter( + (variable: PbBlockVariable) => + variable.id.split(".")[0] === element?.data?.variableId + ); + return variable; + } else { + return null; + } + }, [block, element]); + + return variableValue; +} + +export function useElementVariableValue(element: PbEditorElement | null) { + const elementVariableRendererPlugins = + plugins.byType( + "pb-editor-page-element-variable-renderer" + ); + const elementVariablePlugin = elementVariableRendererPlugins.find( + plugin => plugin.elementType === element?.type + ); + const variableValue = elementVariablePlugin?.getVariableValue(element); + + return variableValue; +} diff --git a/packages/app-page-builder/src/editor/hooks/useParentBlock.ts b/packages/app-page-builder/src/editor/hooks/useParentBlock.ts new file mode 100644 index 00000000000..2a4b52933db --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useParentBlock.ts @@ -0,0 +1,6 @@ +import { useRecoilValue } from "recoil"; +import { blockByElementSelector } from "~/editor/hooks/useCurrentBlockElement"; + +export function useParentBlock(elementId?: string) { + return useRecoilValue(blockByElementSelector(elementId)); +} diff --git a/packages/app-page-builder/src/editor/index.tsx b/packages/app-page-builder/src/editor/index.tsx index ed58fd009b0..7eae17d6b2d 100644 --- a/packages/app-page-builder/src/editor/index.tsx +++ b/packages/app-page-builder/src/editor/index.tsx @@ -13,4 +13,9 @@ export * from "./components/Editor/EditorBar"; export * from "./components/Editor/EditorContent"; export { EditorProvider } from "./contexts/EditorProvider"; export { EditorSidebarTab, EditorSidebarTabProps } from "./components/Editor/EditorSidebar"; +export { SidebarActions } from "./components/Editor/Sidebar/ElementSettingsTabContent"; export { ElementSettingsRenderer } from "./plugins/elementSettings/advanced/ElementSettings"; +export * from "../render/components/ElementRoot"; +export { default as DropZone } from "../editor/components/DropZone"; +export { default as ImageContainer } from "./plugins/elements/image/ImageContainer"; +export { default as ImagesList } from "./plugins/elements/imagesList/ImagesList"; diff --git a/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/index.tsx b/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/index.tsx deleted file mode 100644 index 87907fe17d0..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import preview from "./preview.png"; -import { createElement } from "../../../helpers"; -import { PbEditorBlockPlugin, PbEditorElement } from "~/types"; - -const plugin: PbEditorBlockPlugin = { - name: "pb-editor-block-empty", - type: "pb-editor-block", - category: "general", - title: "Empty block", - /** - * Validate if this is at all possible? Types say it is not. - * TODO @ts-refactor - */ - // @ts-ignore - create(options = {}, parent): PbEditorElement { - return createElement("block", options, parent); - }, - image: { - meta: { - width: 500, - height: 73, - aspectRatio: 500 / 73 - } - }, - preview(): React.ReactElement { - return {"Empty; - } -}; -export default plugin; diff --git a/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/preview.png b/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/preview.png deleted file mode 100644 index 69ae1dd38e8..00000000000 Binary files a/packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/preview.png and /dev/null differ diff --git a/packages/app-page-builder/src/editor/plugins/blocks/gridBlock/preview.png b/packages/app-page-builder/src/editor/plugins/blocks/gridBlock/preview.png deleted file mode 100644 index 69ae1dd38e8..00000000000 Binary files a/packages/app-page-builder/src/editor/plugins/blocks/gridBlock/preview.png and /dev/null differ diff --git a/packages/app-page-builder/src/editor/plugins/blocks/index.ts b/packages/app-page-builder/src/editor/plugins/blocks/index.ts deleted file mode 100644 index 9cbbd3cad05..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import emptyBlock from "./emptyBlock"; -import gridBlock from "./gridBlock"; - -export default [emptyBlock, gridBlock]; diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-gesture-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-gesture-24px.svg deleted file mode 100644 index 28a9e204807..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-gesture-24px.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-group_work-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-group_work-24px.svg deleted file mode 100644 index a1714202a93..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-group_work-24px.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-home-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-home-24px.svg deleted file mode 100644 index 036ba755704..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-home-24px.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-notifications_active-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-notifications_active-24px.svg deleted file mode 100644 index 75453b00e9d..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-notifications_active-24px.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-record_voice_over-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-record_voice_over-24px.svg deleted file mode 100644 index c3078eaeb93..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-record_voice_over-24px.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-stars-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-stars-24px.svg deleted file mode 100644 index f395fb20f5b..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-stars-24px.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-view_quilt-24px.svg b/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-view_quilt-24px.svg deleted file mode 100644 index 67871e9e843..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-view_quilt-24px.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/blocksCategories/index.tsx b/packages/app-page-builder/src/editor/plugins/blocksCategories/index.tsx deleted file mode 100644 index a550578af19..00000000000 --- a/packages/app-page-builder/src/editor/plugins/blocksCategories/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import { PbEditorBlockCategoryPlugin } from "~/types"; -import { ReactComponent as GeneralIcon } from "./icons/round-gesture-24px.svg"; -import { ReactComponent as CtaIcon } from "./icons/round-notifications_active-24px.svg"; -import { ReactComponent as ContentIcon } from "./icons/round-view_quilt-24px.svg"; -import { ReactComponent as FeaturesIcon } from "./icons/round-stars-24px.svg"; -import { ReactComponent as HeaderIcon } from "./icons/round-home-24px.svg"; -import { ReactComponent as TeamIcon } from "./icons/round-group_work-24px.svg"; -import { ReactComponent as TestimonialIcon } from "./icons/round-record_voice_over-24px.svg"; - -const plugins: PbEditorBlockCategoryPlugin[] = [ - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-cta", - title: "Call To Action", - categoryName: "cta", - description: "Call to action blocks.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-content", - title: "Content", - categoryName: "content", - description: "Pre-formatted content blocks.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-features", - title: "Features", - categoryName: "features", - description: "Blocks for listing features and benefits.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-general", - title: "General", - categoryName: "general", - description: "List of general purpose blocks.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-header", - title: "Headers", - categoryName: "header", - description: "Page headers.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-team", - title: "Team", - categoryName: "team", - description: "Blocks to list out your team members.", - icon: - }, - { - type: "pb-editor-block-category", - name: "pb-editor-block-category-testimonial", - title: "Testimonial", - categoryName: "testimonial", - description: "Display comments and user feedback.", - icon: - } -]; - -export default plugins; diff --git a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/ExportPageLoadingDialogContent.tsx b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/ExportPageLoadingDialogContent.tsx index 26fafe48081..717ca0d9f05 100644 --- a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/ExportPageLoadingDialogContent.tsx +++ b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/ExportPageLoadingDialogContent.tsx @@ -119,8 +119,8 @@ const ExportPageLoadingDialogContent: React.FC = {t`{completed} of {total} completed`({ - completed: stats.completed, - total: stats.total + completed: `${stats.completed}`, + total: `${stats.total}` })} + + \ No newline at end of file diff --git a/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/index.tsx b/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/index.tsx new file mode 100644 index 00000000000..04a7ddb373f --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ReactComponent as EmbedsIcon } from "./embeds.svg"; +import { PbEditorPageElementGroupPlugin } from "~/types"; + +export default { + name: "pb-editor-element-group-embeds", + type: "pb-editor-page-element-group", + group: { + title: "Embeds", + icon: + } +} as PbEditorPageElementGroupPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementGroups/index.ts b/packages/app-page-builder/src/editor/plugins/elementGroups/index.ts index da0e49aa1e3..bf3978e46fd 100644 --- a/packages/app-page-builder/src/editor/plugins/elementGroups/index.ts +++ b/packages/app-page-builder/src/editor/plugins/elementGroups/index.ts @@ -1,9 +1,19 @@ import basicGroup from "./basic"; import layoutGroup from "./layout"; import mediaGroup from "./media"; +import embedsGroup from "./embeds"; import formGroup from "./form"; import socialGroup from "./social"; import codeGroup from "./code"; import savedGroup from "./saved"; -export default [basicGroup, formGroup, layoutGroup, mediaGroup, socialGroup, codeGroup, savedGroup]; +export default [ + basicGroup, + formGroup, + layoutGroup, + mediaGroup, + embedsGroup, + socialGroup, + codeGroup, + savedGroup +]; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/action/ActionSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/action/ActionSettings.tsx index 3dcf79a5ec4..9d733fb31e7 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/action/ActionSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/action/ActionSettings.tsx @@ -7,7 +7,7 @@ import { Typography } from "@webiny/ui/Typography"; import { Form } from "@webiny/form"; import { validation } from "@webiny/validation"; import { withActiveElement } from "../../../components"; -import { DelayedOnChange } from "../../../components/DelayedOnChange"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { PbButtonElementClickHandlerPlugin, PbEditorElement, diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/clone/CloneAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/clone/CloneAction.ts index 90d96cabb37..0f9c74e8135 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/clone/CloneAction.ts +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/clone/CloneAction.ts @@ -1,4 +1,5 @@ import React from "react"; +import { cloneDeep } from "lodash"; import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; import { activeElementAtom, elementByIdSelector } from "../../../recoil/modules"; import { plugins } from "@webiny/plugins"; @@ -6,6 +7,16 @@ import { PbEditorPageElementPlugin, PbEditorElement } from "../../../../types"; import { useRecoilValue } from "recoil"; import { CloneElementActionEvent } from "../../../recoil/actions/cloneElement"; +const removeVariableId = (element: PbEditorElement) => { + if (element?.data?.variableId) { + const elementCopy = cloneDeep(element); + delete elementCopy.data.variableId; + + return elementCopy; + } + return element; +}; + interface CloneActionPropsType { children: React.ReactElement; } @@ -19,10 +30,11 @@ const CloneAction: React.FC = ({ children }) => { if (!element) { return null; } + const onClick = () => { eventActionHandler.trigger( new CloneElementActionEvent({ - element + element: removeVariableId(element) }) ); }; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/clone/index.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/clone/index.tsx index dcd83d49e82..42a8519079a 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/clone/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/clone/index.tsx @@ -10,7 +10,7 @@ export default { renderAction() { return ( - } /> + } /> ); } diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/components/SpacingPicker.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/components/SpacingPicker.tsx index 6dbefc1e494..ede08c19e9d 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/components/SpacingPicker.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/components/SpacingPicker.tsx @@ -78,8 +78,8 @@ const SpacingPicker: React.FC = ({ useDefaultStyle = true }) => { const formData = useMemo(() => { - const parsedValue = parseInt(value); - const regx = new RegExp(`${parsedValue}`, "g"); + const parsedValue = parseFloat(value); + const regx = new RegExp(`[0-9.+-]+`, "g"); const unit = value.replace(regx, ""); if (Number.isNaN(parsedValue) && unit === "auto") { @@ -96,16 +96,16 @@ const SpacingPicker: React.FC = ({ const defaultUnitValue: string | undefined = options[0] ? options[0].value : undefined; - const onFormChange = useCallback((formData: SpacingPickerFormData) => { - if (formData.unit === "auto") { - onChange(formData.unit); - return; - } else if (formData.value !== undefined && formData.value !== "") { + const onFormChange = useCallback( + (formData: SpacingPickerFormData) => { + if (formData.unit === "auto") { + onChange(formData.unit); + return; + } onChange(formData.value + (formData.unit || defaultUnitValue)); - return; - } - onChange(""); - }, []); + }, + [defaultUnitValue, onChange] + ); return (
= ({ })} disabled={data.unit === "auto" || disabled} type={"number"} + onFocus={(event: React.FocusEvent) => + event.target.select() + } + onBlur={(event: React.FocusEvent) => { + if (event.target.value === "") { + onChange("0" + (formData.unit || defaultUnitValue)); + } + }} /> 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 b4e2079f46d..7a39d71a057 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 @@ -3,8 +3,24 @@ import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; import { DeleteElementActionEvent } from "../../../recoil/actions"; import { activeElementAtom, elementByIdSelector } from "../../../recoil/modules"; import { plugins } from "@webiny/plugins"; -import { PbEditorPageElementPlugin } from "~/types"; +import { PbEditorPageElementPlugin, PbBlockVariable, PbEditorElement } from "~/types"; import { useRecoilValue } from "recoil"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useParentBlock } from "~/editor/hooks/useParentBlock"; + +const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => { + const updatedVariables = block.data.variables.filter( + (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId + ); + + return { + ...block, + data: { + ...block.data, + variables: updatedVariables + } + }; +}; interface DeleteActionPropsType { children: React.ReactElement; @@ -13,12 +29,20 @@ const DeleteAction: React.FC = ({ children }) => { const eventActionHandler = useEventActionHandler(); const activeElementId = useRecoilValue(activeElementAtom); const element = useRecoilValue(elementByIdSelector(activeElementId as string)); + const block = useParentBlock(activeElementId as string); + const updateElement = useUpdateElement(); if (!element) { return null; } const onClick = useCallback((): void => { + // We need to remove element variable from block if it exists + if (element.data?.variableId && block) { + const updatedBlock = removeVariableFromBlock(block, element.data.variableId); + + updateElement(updatedBlock); + } eventActionHandler.trigger( new DeleteElementActionEvent({ element diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/index.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/index.tsx index c3d1898a06f..8b97b784f20 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/index.tsx @@ -11,7 +11,7 @@ export default { return ( } /> diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/elementSettingsUtils.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/elementSettingsUtils.ts index 5e1b3153fd7..177b5a79c03 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/elementSettingsUtils.ts +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/elementSettingsUtils.ts @@ -95,9 +95,8 @@ export const applyFallbackDisplayMode = ( const currentValue = getValue(orderedConfigs[i].displayMode); // In case of "string", we don't need to merge all values if (currentValue && typeof currentValue === "string") { - return currentValue; - } - if (currentValue) { + output = currentValue; + } else if (currentValue) { output = merge(output, currentValue); } } diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSettings.tsx index a15a4fa8e94..fd87fb053c9 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSettings.tsx @@ -1,196 +1,96 @@ -import React from "react"; -import { css } from "emotion"; -import styled from "@emotion/styled"; +import React, { useMemo } from "react"; import { useRecoilValue } from "recoil"; -import { Grid, Cell } from "@webiny/ui/Grid"; -import { - PbEditorGridPresetPluginType, - PbEditorPageElementSettingsRenderComponentProps, - PbEditorElement -} from "../../../../types"; -import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; -import { createElement } from "../../../helpers"; -import { calculatePresetPluginCells, getPresetPlugins } from "../../../plugins/gridPresets"; -import { UpdateElementActionEvent } from "../../../recoil/actions"; -import { activeElementAtom, elementWithChildrenByIdSelector } from "../../../recoil/modules"; +import get from "lodash/get"; +import set from "lodash/set"; +import merge from "lodash/merge"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { PbEditorPageElementSettingsRenderComponentProps, PbEditorElement } from "~/types"; +import { activeElementAtom, elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useDisplayMode } from "~/editor/hooks/useDisplayMode"; +import { applyFallbackDisplayMode } from "~/editor/plugins/elementSettings/elementSettingsUtils"; // Components -import CellSize from "./CellSize"; -import { ContentWrapper } from "../components/StyledComponents"; -import Accordion from "../components/Accordion"; - -const classes = { - grid: css({ - "&.mdc-layout-grid": { - padding: 0, - marginBottom: 24 - } - }), - icon: css({ - "& .mdc-list-item__graphic > svg": { - width: "18px", - height: "18px" - } - }) -}; - -const StyledIconButton = styled("button")(({ active }: any) => ({ - padding: "0", - margin: "0 2px 2px 0", - background: "transparent", - width: "auto", - height: "auto", - border: "0 none", - cursor: "pointer", - opacity: active ? 1 : 0.7, - "& svg": { - filter: active ? "none" : "grayscale(1)" - }, - ":hover": { - "& svg": { - boxShadow: active ? "none" : "0 0 5px rgba(0, 204, 176, 1)" - } - }, - ":focus": { - outline: "none" - } -})); - -const createCells = (amount: number): PbEditorElement[] => { - return Array(amount) - .fill(0) - .map(() => createElement("cell", {})); -}; - -const resizeCells = (elements: PbEditorElement[], cells: number[]): PbEditorElement[] => { - return elements.map((element, index) => { - return { - ...element, - data: { - ...element.data, - settings: { - ...element.data.settings, - grid: { - size: cells[index] - } - } - } - }; - }); -}; +import Wrapper from "~/editor/plugins/elementSettings/components/Wrapper"; +import SelectField from "~/editor/plugins/elementSettings/components/SelectField"; +import { + ContentWrapper, + classes +} from "~/editor/plugins/elementSettings/components/StyledComponents"; +import Accordion from "~/editor/plugins/elementSettings/components/Accordion"; -const updateChildrenWithPreset = ( - target: PbEditorElement, - pl: PbEditorGridPresetPluginType -): PbEditorElement[] => { - const cells = calculatePresetPluginCells(pl); - const total = target.elements.length; - const max = cells.length; - if (total === max) { - return resizeCells(target.elements as PbEditorElement[], cells); - } else if (total > max) { - return resizeCells(target.elements.slice(0, max) as PbEditorElement[], cells); - } - const created = [...(target.elements as PbEditorElement[]), ...createCells(max - total)]; - return resizeCells(created, cells); -}; +const DATA_NAMESPACE = "data.settings.gridSettings"; export const GridSettings: React.FC = ({ defaultAccordionValue }) => { - const handler = useEventActionHandler(); + const { displayMode, config } = useDisplayMode(); const activeElementId = useRecoilValue(activeElementAtom); const element = useRecoilValue( elementWithChildrenByIdSelector(activeElementId) ) as unknown as PbEditorElement; - const currentCellsType = element.data.settings?.grid?.cellsType; - const presetPlugins = getPresetPlugins(); + const updateElement = useUpdateElement(); + const propName = `${DATA_NAMESPACE}.${displayMode}.flexDirection`; - const onInputSizeChange = (value: number, index: number) => { - const cellElement = element.elements[index] as PbEditorElement; - if (!cellElement) { - throw new Error(`There is no element on index ${index}.`); - } - handler.trigger( - new UpdateElementActionEvent({ - element: { - ...cellElement, - data: { - ...cellElement.data, - settings: { - ...(cellElement.data.settings || {}), - grid: { - size: value - } - } - } - } as any, - history: true - }) - ); - }; + const fallbackValue = useMemo( + () => + applyFallbackDisplayMode(displayMode, mode => + get(element, `${DATA_NAMESPACE}.${mode}.flexDirection`) + ), + [displayMode] + ); - const setPreset = (pl: PbEditorGridPresetPluginType) => { - const cellsType = pl.cellsType; - if (cellsType === currentCellsType) { - return; - } - handler.trigger( - new UpdateElementActionEvent({ - element: { - ...element, - data: { - ...element.data, - settings: { - ...(element.data.settings || {}), - grid: { - cellsType - } - } - }, - elements: updateChildrenWithPreset(element, pl) as any - }, - history: true - }) - ); + const flexDirection = get(element, propName, fallbackValue || "row"); + + const columnWrap = useMemo(() => { + return flexDirection === "row" ? "row" : "column"; + }, [flexDirection]); + + const onClick = (type: any) => { + const newElement = merge({}, element, set({}, propName, type)); + updateElement(newElement); }; - const totalCellsUsed = element.elements.reduce((total, cell) => { - return total + ((cell as PbEditorElement).data.settings?.grid?.size || 1); - }, 0); return ( - + + {config.icon} + + } + > - - {presetPlugins.map(pl => { - const Icon = pl.icon; - return ( - - setPreset(pl)} - active={pl.cellsType === currentCellsType} - > - - - - ); - })} - - - - {element.elements.map((cell, index) => { - const size = (cell as PbEditorElement).data.settings?.grid?.size || 1; - return ( - - onInputSizeChange(value, index)} - maxAllowed={12 - totalCellsUsed} - /> - - ); - })} - + + onClick(value)} + > + + + + + + onClick(value)} + disabled={flexDirection === "row"} + > + + + + ); diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx new file mode 100644 index 00000000000..4f0a0dba217 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { css } from "emotion"; +import styled from "@emotion/styled"; +import { useRecoilValue } from "recoil"; +import { Grid, Cell } from "@webiny/ui/Grid"; +import { + PbEditorGridPresetPluginType, + PbEditorPageElementSettingsRenderComponentProps, + PbEditorElement +} from "../../../../types"; +import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; +import { createElement } from "../../../helpers"; +import { calculatePresetPluginCells, getPresetPlugins } from "../../../plugins/gridPresets"; +import { UpdateElementActionEvent } from "../../../recoil/actions"; +import { activeElementAtom, elementWithChildrenByIdSelector } from "../../../recoil/modules"; +// Components +import CellSize from "./CellSize"; +import { ContentWrapper } from "../components/StyledComponents"; +import Accordion from "../components/Accordion"; + +const classes = { + grid: css({ + "&.mdc-layout-grid": { + padding: 0, + marginBottom: 24 + } + }), + icon: css({ + "& .mdc-list-item__graphic > svg": { + width: "18px", + height: "18px" + } + }) +}; + +const StyledIconButton = styled("button")(({ active }: any) => ({ + padding: "0", + margin: "0 2px 2px 0", + background: "transparent", + width: "auto", + height: "auto", + border: "0 none", + cursor: "pointer", + opacity: active ? 1 : 0.7, + "& svg": { + filter: active ? "none" : "grayscale(1)" + }, + ":hover": { + "& svg": { + boxShadow: active ? "none" : "0 0 5px rgba(0, 204, 176, 1)" + } + }, + ":focus": { + outline: "none" + } +})); + +const createCells = (amount: number): PbEditorElement[] => { + return Array(amount) + .fill(0) + .map(() => createElement("cell", {})); +}; + +const resizeCells = (elements: PbEditorElement[], cells: number[]): PbEditorElement[] => { + return elements.map((element, index) => { + return { + ...element, + data: { + ...element.data, + settings: { + ...element.data.settings, + grid: { + size: cells[index] + } + } + } + }; + }); +}; + +const updateChildrenWithPreset = ( + target: PbEditorElement, + pl: PbEditorGridPresetPluginType +): PbEditorElement[] => { + const cells = calculatePresetPluginCells(pl); + const total = target.elements.length; + const max = cells.length; + if (total === max) { + return resizeCells(target.elements as PbEditorElement[], cells); + } else if (total > max) { + return resizeCells(target.elements.slice(0, max) as PbEditorElement[], cells); + } + const created = [...(target.elements as PbEditorElement[]), ...createCells(max - total)]; + return resizeCells(created, cells); +}; + +export const GridSize: React.FC = ({ + defaultAccordionValue +}) => { + const handler = useEventActionHandler(); + const activeElementId = useRecoilValue(activeElementAtom); + const element = useRecoilValue( + elementWithChildrenByIdSelector(activeElementId) + ) as unknown as PbEditorElement; + const currentCellsType = element.data.settings?.grid?.cellsType; + const presetPlugins = getPresetPlugins(); + + const onInputSizeChange = (value: number, index: number) => { + const cellElement = element.elements[index] as PbEditorElement; + if (!cellElement) { + throw new Error(`There is no element on index ${index}.`); + } + handler.trigger( + new UpdateElementActionEvent({ + element: { + ...cellElement, + data: { + ...cellElement.data, + settings: { + ...(cellElement.data.settings || {}), + grid: { + size: value + } + } + } + } as any, + history: true + }) + ); + }; + + const setPreset = (pl: PbEditorGridPresetPluginType) => { + const cellsType = pl.cellsType; + if (cellsType === currentCellsType) { + return; + } + handler.trigger( + new UpdateElementActionEvent({ + element: { + ...element, + data: { + ...element.data, + settings: { + ...(element.data.settings || {}), + grid: { + cellsType + } + } + }, + elements: updateChildrenWithPreset(element, pl) as any + }, + history: true + }) + ); + }; + const totalCellsUsed = element.elements.reduce((total, cell) => { + return total + ((cell as PbEditorElement).data.settings?.grid?.size || 1); + }, 0); + + return ( + + + + {presetPlugins.map(pl => { + const Icon = pl.icon; + return ( + + setPreset(pl)} + active={pl.cellsType === currentCellsType} + > + + + + ); + })} + + + + {element.elements.map((cell, index) => { + const size = (cell as PbEditorElement).data.settings?.grid?.size || 1; + return ( + + onInputSizeChange(value, index)} + maxAllowed={12 - totalCellsUsed} + /> + + ); + })} + + + + ); +}; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/index.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/index.tsx index 3fce0a8b46e..9c57739ecfb 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/index.tsx @@ -1,11 +1,21 @@ import React from "react"; +import { GridSize } from "./GridSize"; import { GridSettings } from "./GridSettings"; import { PbEditorPageElementStyleSettingsPlugin } from "../../../../types"; -export default { - name: "pb-editor-page-element-style-settings-grid", - type: "pb-editor-page-element-style-settings", - render() { - return ; - } -} as PbEditorPageElementStyleSettingsPlugin; +export default [ + { + name: "pb-editor-page-element-style-settings-grid", + type: "pb-editor-page-element-style-settings", + render() { + return ; + } + } as PbEditorPageElementStyleSettingsPlugin, + { + name: "pb-editor-page-element-style-settings-grid-settings", + type: "pb-editor-page-element-style-settings", + render() { + return ; + } + } as PbEditorPageElementStyleSettingsPlugin +]; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/link/HrefSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/link/HrefSettings.tsx index cab7aa1a8ec..e8ba77a6b29 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/link/HrefSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/link/HrefSettings.tsx @@ -5,7 +5,7 @@ import { Switch } from "@webiny/ui/Switch"; import { Form } from "@webiny/form"; import { validation } from "@webiny/validation"; import { withActiveElement } from "~/editor/components"; -import { DelayedOnChange } from "~/editor/components/DelayedOnChange"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { PbEditorPageElementSettingsRenderComponentProps, PbEditorElement } from "~/types"; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveAction.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveAction.tsx index 161707f8afa..2213b604de4 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveAction.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveAction.tsx @@ -15,6 +15,11 @@ import { plugins } from "@webiny/plugins"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { useKeyHandler } from "../../../hooks/useKeyHandler"; import { CREATE_PAGE_ELEMENT, UPDATE_PAGE_ELEMENT } from "~/admin/graphql/pages"; +import { + CREATE_PAGE_BLOCK, + UPDATE_PAGE_BLOCK, + LIST_PAGE_BLOCKS_AND_CATEGORIES +} from "~/admin/views/PageBlocks/graphql"; import { useRecoilValue } from "recoil"; import { CREATE_FILE } from "./SaveDialog/graphql"; import { FileUploaderPlugin } from "@webiny/app/types"; @@ -26,23 +31,7 @@ import { PbEditorElement } from "~/types"; import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; - -const removeIds = (el: PbElement): PbElement => { - // @ts-ignore - delete el.id; - - el.elements = el.elements.map(el => { - // @ts-ignore - delete el.id; - if (el.elements && el.elements.length) { - el = removeIds(el); - } - - return el; - }); - - return el; -}; +import { removeElementId } from "~/editor/helpers"; interface ImageDimensionsType { width: number; @@ -89,7 +78,8 @@ const SaveAction: React.FC = ({ children }) => { const client = useApolloClient(); const onSubmit = async (formData: PbDocumentElement) => { - formData.content = pluginOnSave(removeIds((await getElementTree(element)) as PbElement)); + const pbElement = (await getElementTree({ element })) as PbElement; + formData.content = pluginOnSave(removeElementId(pbElement)); const meta = await getDataURLImageDimensions(formData.preview); const blob = dataURLtoBlob(formData.preview); @@ -124,33 +114,62 @@ const SaveAction: React.FC = ({ children }) => { formData.preview = createdImage.data; - const query = formData.overwrite ? UPDATE_PAGE_ELEMENT : CREATE_PAGE_ELEMENT; + if (formData.type === "block") { + const query = formData.overwrite ? UPDATE_PAGE_BLOCK : CREATE_PAGE_BLOCK; + + const { data: res } = await client.mutate({ + mutation: query, + variables: formData.overwrite + ? { + id: element.source, + data: pick(formData, ["content", "preview"]) + } + : { data: pick(formData, ["name", "blockCategory", "preview", "content"]) }, + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }] + }); + + const { error, data } = get(res, `pageBuilder.pageBlock`); + + if (error) { + showSnackbar(error.message); + return; + } - const { data: res } = await client.mutate({ - mutation: query, - variables: formData.overwrite - ? { - id: element.source, - data: pick(formData, ["content", "preview"]) - } - : { data: pick(formData, ["type", "category", "preview", "name", "content"]) } - }); + hideDialog(); - hideDialog(); - const mutationName = formData.overwrite ? "updatePageElement" : "createPageElement"; - const data = get(res, `pageBuilder.${mutationName}.data`); - if (data.type === "block") { createBlockPlugin(data); + showSnackbar( + + {formData.type[0].toUpperCase() + formData.type.slice(1)}{" "} + {data.name} was saved! + + ); } else { + const query = formData.overwrite ? UPDATE_PAGE_ELEMENT : CREATE_PAGE_ELEMENT; + + const { data: res } = await client.mutate({ + mutation: query, + variables: formData.overwrite + ? { + id: element.source, + data: pick(formData, ["content", "preview"]) + } + : { data: pick(formData, ["type", "category", "preview", "name", "content"]) } + }); + + hideDialog(); + const mutationName = formData.overwrite ? "updatePageElement" : "createPageElement"; + const data = get(res, `pageBuilder.${mutationName}.data`); + createElementPlugin(data); - } - showSnackbar( - - {formData.type[0].toUpperCase() + formData.type.slice(1)}{" "} - {data.name} was saved! - - ); + showSnackbar( + + {formData.type[0].toUpperCase() + formData.type.slice(1)}{" "} + {data.name} was saved! + + ); + } }; useEffect(() => { diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx index ed66c4cff93..e49db0896a3 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx @@ -106,7 +106,7 @@ const SaveDialog = (props: Props) => { { + onChange(textToBlockQuote(value)); + setLocalValue(textToBlockQuote(value)); + }} + onBlur={onBlur} + /> + ); +}; + +export default QuoteVariableInput; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/RichVariableInput.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/RichVariableInput.tsx new file mode 100644 index 00000000000..9307ad3becb --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/RichVariableInput.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import styled from "@emotion/styled"; +import { CoreOptions } from "medium-editor"; +import ReactMediumEditor from "~/editor/components/MediumEditor"; +import { useVariable } from "~/hooks/useVariable"; + +const InputWrapper = styled("div")({ + padding: "20px 16px", + backgroundColor: "rgba(212, 212, 212, 0.5)", + borderBottom: "1px solid", + + "&:hover": { + backgroundColor: "rgba(212, 212, 212, 0.7)" + }, + + "&>p": { + minHeight: "auto", + lineHeight: "normal" + } +}); + +const DEFAULT_EDITOR_OPTIONS: CoreOptions = { + toolbar: { + buttons: ["bold", "italic", "underline", "anchor"] + }, + anchor: { + targetCheckbox: true, + targetCheckboxText: "Open in a new tab" + } +}; + +interface RichVariableInputProps { + variableId: string; +} + +const RichVariableInput: React.FC = ({ variableId }) => { + const { value, onChange, onBlur } = useVariable(variableId); + const [initialValue] = useState(value); + + return ( + + + + ); +}; + +export default RichVariableInput; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/SingleImageVariableInput.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/SingleImageVariableInput.tsx new file mode 100644 index 00000000000..803ba6c861a --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/SingleImageVariableInput.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { useVariable } from "~/hooks/useVariable"; +import SingleImageUpload from "@webiny/app-admin/components/SingleImageUpload"; + +type ImageData = { + id: string; + key: string; + name: string; + size: number; + src: string; + type: string; +}; + +interface SingleImageVariableInputProps { + variableId: string; +} + +const SingleImageVariableInput: React.FC = ({ variableId }) => { + const { value, onChange } = useVariable(variableId); + + return ( + onChange(value, true)} value={value} /> + ); +}; + +export default SingleImageVariableInput; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/TextVariableInput.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/TextVariableInput.tsx new file mode 100644 index 00000000000..15e7cc6ea7a --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/TextVariableInput.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from "react"; +import { Input } from "@webiny/ui/Input"; +import { useVariable } from "~/hooks/useVariable"; + +interface TextVariableInputProps { + variableId: string; +} + +const TextVariableInput: React.FC = ({ variableId }) => { + const { value, onChange, onBlur } = useVariable(variableId); + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + if (localValue !== value) { + setLocalValue(value); + } + }, [value]); + + return ( + { + onChange(value); + setLocalValue(value); + }} + onBlur={onBlur} + /> + ); +}; + +export default TextVariableInput; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx new file mode 100644 index 00000000000..2a357992401 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { css } from "emotion"; +import { plugins } from "@webiny/plugins"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { Typography } from "@webiny/ui/Typography"; +import { PbBlockVariable, PbEditorPageElementVariableRendererPlugin } from "~/types"; + +const wrapperStyle = css({ + padding: "16px", + display: "grid", + rowGap: "20px" +}); + +const labelStyle = css({ + marginBottom: "8px", + "& span": { + color: "var(--mdc-theme-text-primary-on-background)" + } +}); + +const VariableSettings: React.FC = () => { + const [element] = useActiveElement(); + + const elementVariableRendererPlugins = + plugins.byType( + "pb-editor-page-element-variable-renderer" + ); + + return ( +
+ {element?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( +
+
+ {variable.label} +
+ {elementVariableRendererPlugins + .find(plugin => plugin.elementType === variable?.type) + ?.renderVariableInput(variable.id)} +
+ ))} +
+ ); +}; + +export default VariableSettings; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/button/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/button/index.tsx new file mode 100644 index 00000000000..6a30e09c020 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/button/index.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { PbBlockVariable, PbEditorPageElementVariableRendererPlugin } from "~/types"; +import TextVariableInput from "~/editor/plugins/elementSettings/variable/TextVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-button", + type: "pb-editor-page-element-variable-renderer", + elementType: "button", + getVariableValue(element) { + const variables = useElementVariables(element); + + return { + label: + variables?.find((variable: PbBlockVariable) => + variable.id.endsWith(".label") + )?.value || null, + url: + variables?.find((variable: PbBlockVariable) => variable.id.endsWith(".url")) + ?.value || null + }; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newLabel = variables?.find((variable: PbBlockVariable) => + variable.id.endsWith(".label") + )?.value; + const newUrl = variables?.find((variable: PbBlockVariable) => + variable.id.endsWith(".url") + )?.value; + + if (newLabel && element?.data) { + element.data.buttonText = newLabel; + } + if (newUrl && element?.data?.action) { + element.data.action.href = newUrl; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/heading/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/heading/index.tsx new file mode 100644 index 00000000000..f7e281c0e6e --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/heading/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import TextVariableInput from "~/editor/plugins/elementSettings/variable/TextVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-heading", + type: "pb-editor-page-element-variable-renderer", + elementType: "heading", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newText = variables?.length > 0 ? variables[0].value : null; + + if (newText && element?.data?.text?.data) { + element.data.text.data.text = newText; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/image/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/image/index.tsx new file mode 100644 index 00000000000..927a28c19d5 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/image/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import SingleImageVariableInput from "~/editor/plugins/elementSettings/variable/SingleImageVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-image", + type: "pb-editor-page-element-variable-renderer", + elementType: "image", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newFile = variables?.length > 0 ? variables[0].value : null; + + if (newFile && element?.data?.image) { + element.data.image.file = newFile; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/images-list/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/images-list/index.tsx new file mode 100644 index 00000000000..4c264fe3fc5 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/images-list/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import MultipleImageVariableInput from "~/editor/plugins/elementSettings/variable/MultipleImageVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-images-list", + type: "pb-editor-page-element-variable-renderer", + elementType: "images-list", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newImages = variables?.length > 0 ? variables[0].value : null; + + if (newImages && element?.data?.images) { + element.data.images = newImages; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/index.ts b/packages/app-page-builder/src/editor/plugins/elementVariables/index.ts new file mode 100644 index 00000000000..6acd8ac3c23 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/index.ts @@ -0,0 +1,9 @@ +import button from "./button"; +import heading from "./heading"; +import image from "./image"; +import imagesList from "./images-list"; +import list from "./list"; +import paragraph from "./paragraph"; +import quote from "./quote"; + +export default () => [button, heading, image, imagesList, list, paragraph, quote]; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/list/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/list/index.tsx new file mode 100644 index 00000000000..bcd5dae1449 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/list/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import RichVariableInput from "~/editor/plugins/elementSettings/variable/RichVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-list", + type: "pb-editor-page-element-variable-renderer", + elementType: "list", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newText = variables?.length > 0 ? variables[0].value : null; + + if (newText && element?.data?.text?.data) { + element.data.text.data.text = newText; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/paragraph/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/paragraph/index.tsx new file mode 100644 index 00000000000..537cafdb822 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/paragraph/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import RichVariableInput from "~/editor/plugins/elementSettings/variable/RichVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-paragraph", + type: "pb-editor-page-element-variable-renderer", + elementType: "paragraph", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newText = variables?.length > 0 ? variables[0].value : null; + + if (newText && element?.data?.text?.data) { + element.data.text.data.text = newText; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; diff --git a/packages/app-page-builder/src/editor/plugins/elementVariables/quote/index.tsx b/packages/app-page-builder/src/editor/plugins/elementVariables/quote/index.tsx new file mode 100644 index 00000000000..386a184d28a --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementVariables/quote/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PbEditorPageElementVariableRendererPlugin } from "~/types"; +import QuoteVariableInput from "~/editor/plugins/elementSettings/variable/QuoteVariableInput"; +import { useElementVariables } from "~/editor/hooks/useElementVariableValue"; + +export default { + name: "pb-editor-page-element-variable-renderer-quote", + type: "pb-editor-page-element-variable-renderer", + elementType: "quote", + getVariableValue(element) { + const variables = useElementVariables(element); + return variables?.length > 0 ? variables[0].value : null; + }, + renderVariableInput(variableId: string) { + return ; + }, + setElementValue(element, variables) { + const newText = variables?.length > 0 ? variables[0].value : null; + + if (newText && element?.data?.text?.data) { + element.data.text.data.text = newText; + } + + return element; + } +} as PbEditorPageElementVariableRendererPlugin; 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 9be087c609e..07d333840e1 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 @@ -8,7 +8,6 @@ import { ElementRoot } from "~/render/components/ElementRoot"; const BlockStyle = styled("div")({ position: "relative", color: "#666", - padding: 5, boxSizing: "border-box" }); interface BlockType { diff --git a/packages/app-page-builder/src/editor/plugins/elements/button/ButtonContainer.tsx b/packages/app-page-builder/src/editor/plugins/elements/button/ButtonContainer.tsx index fa144b7afef..e62d3565e0d 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/button/ButtonContainer.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/button/ButtonContainer.tsx @@ -10,6 +10,7 @@ import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { elementByIdSelector, uiAtom } from "~/editor/recoil/modules"; import SimpleEditableText from "./SimpleEditableText"; +import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; const buttonEditStyle = css({ "&.button__content--empty": { @@ -37,6 +38,7 @@ const ButtonContainer: React.FC = ({ const uiAtomValue = useRecoilValue(uiAtom); const element = useRecoilValue(elementByIdSelector(elementId)) as PbEditorElement; const { type = "default", icon = {}, buttonText } = element.data || {}; + const variableValue = useElementVariableValue(element); const defaultValue = typeof buttonText === "string" ? buttonText : "Click me"; const value = useRef(defaultValue); @@ -101,6 +103,7 @@ const ButtonContainer: React.FC = ({ "button__content--empty": !value.current })} value={value.current} + variableValue={variableValue?.label} onChange={onChange} onBlur={onBlur} /> diff --git a/packages/app-page-builder/src/editor/plugins/elements/button/SimpleEditableText.tsx b/packages/app-page-builder/src/editor/plugins/elements/button/SimpleEditableText.tsx index 943c72f37f6..2c4c3f9ddcc 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/button/SimpleEditableText.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/button/SimpleEditableText.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react"; interface SimpleTextPropsType { value?: string; + variableValue?: string; onFocus?: () => void; onBlur?: () => void; onChange: (value: string) => void; @@ -12,6 +13,7 @@ interface SimpleTextPropsType { } const SimpleEditableText: React.FC = ({ value: defaultValue = "", + variableValue, onFocus, onBlur, onChange, @@ -60,7 +62,7 @@ const SimpleEditableText: React.FC = ({ onBlur: onBlurHandler, onFocus: onFocusHandler, dangerouslySetInnerHTML: { - __html: value.current + __html: variableValue || value.current }, ref: inputRef, ...options diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrame.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/IFrame.tsx similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrame.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/IFrame.tsx diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/IFrameSettings.tsx similarity index 89% rename from packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/IFrameSettings.tsx index 703a0c61b9c..47672a0aeb0 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/IFrameSettings.tsx @@ -2,9 +2,8 @@ import React from "react"; import { css } from "emotion"; import { merge } from "dot-prop-immutable"; import { Form } from "@webiny/form"; -import { validation } from "@webiny/validation"; import { withActiveElement } from "~/editor/components"; -import { DelayedOnChange } from "~/editor/components/DelayedOnChange"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { PbEditorPageElementSettingsRenderComponentProps, PbEditorElement } from "~/types"; @@ -24,6 +23,15 @@ const classes = { justifySelf: "end" }) }; + +const isValidUrl = (urlString: string) => { + try { + return Boolean(new URL(urlString)); + } catch (e) { + return false; + } +}; + interface LinkSettingsFormData { url?: string; } @@ -53,9 +61,7 @@ const LinkSettingsComponent: React.FC< return; } - const isValidUrl = data.url && validation.validateSync(data.url, "url:allowHref"); - - if (!isValidUrl) { + if (data.url && !isValidUrl(data.url)) { return; } @@ -70,7 +76,7 @@ const LinkSettingsComponent: React.FC< {({ Bind }) => ( <> - + {props => ( { elementType: "iframe", toolbar: { title: "iFrame", - group: "pb-editor-element-group-media", + group: "pb-editor-element-group-embeds", preview() { return ( diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/soundcloud/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/soundcloud/index.tsx similarity index 98% rename from packages/app-page-builder/src/editor/plugins/elements/media/soundcloud/index.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/soundcloud/index.tsx index e8e6c03dc0b..b78329f205d 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/soundcloud/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/embeds/soundcloud/index.tsx @@ -26,7 +26,7 @@ export default (args: PbEditorElementPluginArgs = {}) => { const elementType = kebabCase(args.elementType || "soundcloud"); const defaultToolbar = { title: "Soundcloud", - group: "pb-editor-element-group-media", + group: "pb-editor-element-group-embeds", preview() { return ( diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/soundcloud/soundcloud-brands.svg b/packages/app-page-builder/src/editor/plugins/elements/embeds/soundcloud/soundcloud-brands.svg similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/soundcloud/soundcloud-brands.svg rename to packages/app-page-builder/src/editor/plugins/elements/embeds/soundcloud/soundcloud-brands.svg diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/vimeo/VimeoEmbed.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/VimeoEmbed.tsx similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/vimeo/VimeoEmbed.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/VimeoEmbed.tsx diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/vimeo/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/index.tsx similarity index 98% rename from packages/app-page-builder/src/editor/plugins/elements/media/vimeo/index.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/index.tsx index 3f0ffc8698c..0448f56ed51 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/vimeo/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/index.tsx @@ -27,7 +27,7 @@ export default (args: PbEditorElementPluginArgs = {}) => { const elementType = kebabCase(args.elementType || "vimeo"); const defaultToolbar = { title: "Vimeo", - group: "pb-editor-element-group-media", + group: "pb-editor-element-group-embeds", preview() { return ( diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/vimeo/vimeo-v-brands.svg b/packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/vimeo-v-brands.svg similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/vimeo/vimeo-v-brands.svg rename to packages/app-page-builder/src/editor/plugins/elements/embeds/vimeo/vimeo-v-brands.svg diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/youtube/YoutubeEmbed.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/YoutubeEmbed.tsx similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/youtube/YoutubeEmbed.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/YoutubeEmbed.tsx diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/youtube/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/index.tsx similarity index 98% rename from packages/app-page-builder/src/editor/plugins/elements/media/youtube/index.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/index.tsx index c46fd32d229..7d7db323c24 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/youtube/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/index.tsx @@ -29,7 +29,7 @@ export default (args: PbEditorElementPluginArgs = {}) => { const elementType = kebabCase(args.elementType || "youtube"); const defaultToolbar = { title: "Youtube", - group: "pb-editor-element-group-media", + group: "pb-editor-element-group-embeds", preview() { return ( diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/youtube/placeholder.png b/packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/placeholder.png similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/youtube/placeholder.png rename to packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/placeholder.png diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/youtube/youtube-brands.svg b/packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/youtube-brands.svg similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/youtube/youtube-brands.svg rename to packages/app-page-builder/src/editor/plugins/elements/embeds/youtube/youtube-brands.svg diff --git a/packages/app-page-builder/src/editor/plugins/elements/grid/GridContainer.tsx b/packages/app-page-builder/src/editor/plugins/elements/grid/GridContainer.tsx index e9719764dc6..c8f96ce9853 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/grid/GridContainer.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/grid/GridContainer.tsx @@ -11,7 +11,6 @@ import { elementWithChildrenByIdSelector, uiAtom } from "~/editor/recoil/modules const GridContainerStyle = styled("div")({ position: "relative", color: "#666", - padding: 5, boxSizing: "border-box", display: "flex" }); diff --git a/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx index 1c626befec9..843c0b8ed93 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx @@ -35,6 +35,7 @@ const createDefaultCells = (cellsType: string) => { export default (args: PbEditorElementPluginArgs = {}): PbEditorPageElementPlugin => { const defaultSettings = [ "pb-editor-page-element-style-settings-grid", + "pb-editor-page-element-style-settings-grid-settings", "pb-editor-page-element-style-settings-background", "pb-editor-page-element-style-settings-animation", "pb-editor-page-element-style-settings-border", @@ -111,6 +112,16 @@ export default (args: PbEditorElementPluginArgs = {}): PbEditorPageElementPlugin grid: { cellsType }, + gridSettings: { + ...createInitialPerDeviceSettingValue( + { flexDirection: "row" }, + DisplayMode.DESKTOP + ), + ...createInitialPerDeviceSettingValue( + { flexDirection: "column" }, + DisplayMode.MOBILE_LANDSCAPE + ) + }, horizontalAlignFlex: createInitialPerDeviceSettingValue( "flex-start", DisplayMode.DESKTOP diff --git a/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx index 0e9646066a0..8fc68c1d45f 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx @@ -1,16 +1,14 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRecoilValue } from "recoil"; import styled from "@emotion/styled"; +import get from "lodash/get"; import SingleImageUpload from "@webiny/app-admin/components/SingleImageUpload"; -import { - DisplayMode, - PbEditorElement, - PbElementDataImageType, - PbElementDataSettingsType -} from "~/types"; +import { PbEditorElement, PbElementDataImageType } from "~/types"; import { uiAtom } from "~/editor/recoil/modules"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; +import { makeComposable } from "@webiny/react-composition"; +import { applyFallbackDisplayMode } from "~/editor/plugins/elementSettings/elementSettingsUtils"; const AlignImage = styled("div")((props: any) => ({ img: { @@ -18,16 +16,6 @@ const AlignImage = styled("div")((props: any) => ({ } })); -const getHorizontalAlignFlexAlign = ( - element: PbEditorElement | null, - displayMode: DisplayMode -): PbElementDataSettingsType["horizontalAlignFlex"] => { - if (!element || !element.data || !element.data.settings) { - return "center"; - } - return (element.data.settings.horizontalAlignFlex as any)[displayMode] || "center"; -}; - interface ImageContainerType { element: PbEditorElement; } @@ -39,7 +27,23 @@ const ImageContainer: React.FC = ({ element }) => { const image = element?.data?.image || {}; // Use per-device style - const align = getHorizontalAlignFlexAlign(element, displayMode); + const align = useMemo(() => { + const elementValue = get(element, `data.settings.horizontalAlignFlex.${displayMode}`); + + if (elementValue) { + return elementValue; + } + + const fallbackValue = applyFallbackDisplayMode(displayMode, mode => + get(element, `data.settings.horizontalAlignFlex.${mode}`) + ); + + if (fallbackValue) { + return fallbackValue; + } + + return "center"; + }, [displayMode, element]); const imgStyle: PbElementDataImageType = {}; if (!!image.width) { @@ -87,4 +91,9 @@ const ImageContainer: React.FC = ({ element }) => { ); }; -export default React.memo(ImageContainer); +export default makeComposable( + "ImageContainer", + React.memo(({ element }: { element: PbEditorElement }) => { + return ; + }) +); diff --git a/packages/app-page-builder/src/editor/plugins/elements/imagesList/ImagesList.tsx b/packages/app-page-builder/src/editor/plugins/elements/imagesList/ImagesList.tsx index 90aab6452e4..5ec59971869 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/imagesList/ImagesList.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/imagesList/ImagesList.tsx @@ -1,18 +1,15 @@ import * as React from "react"; import { usePageBuilder } from "~/hooks/usePageBuilder"; import { plugins } from "@webiny/plugins"; -import { PbPageElementImagesListComponentPlugin } from "~/types"; -import { Image } from "react-images"; +import { PbEditorElement, PbPageElementImagesListComponentPlugin } from "~/types"; +import { makeComposable } from "@webiny/react-composition"; interface ImagesListProps { - data: { - component: string; - images: Image[]; - }; + element: PbEditorElement; } -const ImagesList: React.FC = ({ data }) => { +const ImagesList: React.FC = ({ element }) => { const { theme } = usePageBuilder(); - const { component, images } = data; + const { component, images } = element.data; const components = plugins.byType( "pb-page-element-images-list-component" ); @@ -30,4 +27,9 @@ const ImagesList: React.FC = ({ data }) => { return ; }; -export default React.memo(ImagesList); +export default makeComposable( + "ImagesList", + React.memo(({ element }: { element: PbEditorElement }) => { + return ; + }) +); diff --git a/packages/app-page-builder/src/editor/plugins/elements/imagesList/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/imagesList/index.tsx index 862cd6c25e2..1564ec9e05c 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/imagesList/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/imagesList/index.tsx @@ -76,8 +76,7 @@ export default (args: PbEditorElementPluginArgs = {}) => { return typeof args.create === "function" ? args.create(defaultValue) : defaultValue; }, render({ element }) { - // TODO @ts-refactor - return ; + return ; } } as PbEditorPageElementPlugin, { diff --git a/packages/app-page-builder/src/editor/plugins/toolbar/addElement/AddElement.tsx b/packages/app-page-builder/src/editor/plugins/toolbar/addElement/AddElement.tsx index 20174f62555..e65f0893ac8 100644 --- a/packages/app-page-builder/src/editor/plugins/toolbar/addElement/AddElement.tsx +++ b/packages/app-page-builder/src/editor/plugins/toolbar/addElement/AddElement.tsx @@ -65,7 +65,21 @@ const AddElement: React.FC = () => { handler.trigger(new DropElementActionEvent(args)); }, []); const getGroups = useCallback((): PbEditorPageElementGroupPlugin[] => { - return plugins.byType("pb-editor-page-element-group"); + const allGroups = plugins.byType( + "pb-editor-page-element-group" + ); + + // For backward compatibility with custom media plugins (if such were created by user) + const isMediaGroupEmpty = + plugins + .byType("pb-editor-page-element") + .filter(el => el.toolbar && el.toolbar.group === "pb-editor-element-group-media") + .length === 0; + if (isMediaGroupEmpty) { + return allGroups.filter(group => group.name !== "pb-editor-element-group-media"); + } + + return allGroups; }, []); const pageElementGroupPlugins = getGroups(); diff --git a/packages/app-page-builder/src/editor/plugins/toolbar/undoRedo/index.tsx b/packages/app-page-builder/src/editor/plugins/toolbar/undoRedo/index.tsx index 21352b92c3b..5364ddac9f6 100644 --- a/packages/app-page-builder/src/editor/plugins/toolbar/undoRedo/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/toolbar/undoRedo/index.tsx @@ -6,7 +6,6 @@ import { PbEditorToolbarBottomPlugin } from "~/types"; import Action from "../Action"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { useActiveElement } from "~/editor/hooks/useActiveElement"; -import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; const osFamily = platform.os ? platform.os.family : null; const metaKey = osFamily === "OS X" ? "CMD" : "CTRL"; @@ -15,13 +14,12 @@ export const undo: PbEditorToolbarBottomPlugin = { name: "pb-editor-toolbar-undo", type: "pb-editor-toolbar-bottom", renderAction() { - const { undo, trigger } = useEventActionHandler(); + const { undo } = useEventActionHandler(); const [, setActiveElement] = useActiveElement(); - const onClick = async () => { + const onClick = () => { undo(); setActiveElement(null); - await trigger(new UpdateDocumentActionEvent({ history: false, debounce: true })); }; return ( { + const onClick = () => { setActiveElement(null); redo(); - await trigger(new UpdateDocumentActionEvent({ history: false, debounce: true })); }; return ( diff --git a/packages/app-page-builder/src/hooks/useVariable.ts b/packages/app-page-builder/src/hooks/useVariable.ts new file mode 100644 index 00000000000..e0c98c6388a --- /dev/null +++ b/packages/app-page-builder/src/hooks/useVariable.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { PbBlockVariable } from "~/types"; + +export function useVariable(variableId: string) { + const [element] = useActiveElement(); + const updateElement = useUpdateElement(); + const variable = element?.data?.variables?.find( + (variable: PbBlockVariable) => variable.id === variableId + ); + + const onChange = useCallback( + (value: any, history = false) => { + if (element) { + const newVariables = element?.data?.variables?.map((variable: PbBlockVariable) => { + if (variable?.id === variableId) { + return { + ...variable, + value + }; + } + + return variable; + }); + updateElement( + { + ...element, + data: { + ...element.data, + variables: newVariables + } + }, + { + history + } + ); + } + }, + [element, variableId, updateElement] + ); + + const onBlur = useCallback(() => { + if (element) { + updateElement(element); + } + }, [element, updateElement]); + + return { value: variable?.value, onChange, onBlur }; +} diff --git a/packages/app-page-builder/src/modules/WebsiteSettings/settingsGroups/FaviconAndLogo.tsx b/packages/app-page-builder/src/modules/WebsiteSettings/settingsGroups/FaviconAndLogo.tsx index bf638840f5d..f1891abb92f 100644 --- a/packages/app-page-builder/src/modules/WebsiteSettings/settingsGroups/FaviconAndLogo.tsx +++ b/packages/app-page-builder/src/modules/WebsiteSettings/settingsGroups/FaviconAndLogo.tsx @@ -14,6 +14,9 @@ export const FaviconAndLogo: React.FC = () => { onChangePick={["id", "src"]} label="Favicon" accept={["image/png", "image/x-icon", "image/vnd.microsoft.icon"]} + imagePreviewProps={{ + style: { height: 91 } + }} description={ Supported file types: .png and{" "} diff --git a/packages/app-page-builder/src/pageEditor/Editor.tsx b/packages/app-page-builder/src/pageEditor/Editor.tsx index 21cbc372efb..925a4bd70ef 100644 --- a/packages/app-page-builder/src/pageEditor/Editor.tsx +++ b/packages/app-page-builder/src/pageEditor/Editor.tsx @@ -1,10 +1,11 @@ import React, { useMemo, useState } from "react"; import { useApolloClient, useMutation } from "@apollo/react-hooks"; +import { plugins } from "@webiny/plugins"; import { useRouter } from "@webiny/react-router"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import get from "lodash/get"; import { Editor as PbEditor } from "~/admin/components/Editor"; -import { createElement } from "~/editor/helpers"; +import { createElement, addElementId } from "~/editor/helpers"; import { GET_PAGE, CREATE_PAGE_FROM, @@ -19,13 +20,17 @@ import { ListPageElementsQueryResponse, ListPageElementsQueryResponseData } from "~/admin/graphql/pages"; +import { LIST_PAGE_BLOCKS, ListPageBlocksQueryResponse } from "~/admin/views/PageBlocks/graphql"; +import { LIST_BLOCK_CATEGORIES } from "~/admin/views/BlockCategories/graphql"; import createElementPlugin from "~/admin/utils/createElementPlugin"; import createBlockPlugin from "~/admin/utils/createBlockPlugin"; import dotProp from "dot-prop-immutable"; -import { PbErrorResponse } from "~/types"; +import { PbErrorResponse, PbPageBlock, PbBlockCategory, PbEditorElement } from "~/types"; +import createBlockCategoryPlugin from "~/admin/utils/createBlockCategoryPlugin"; import { PageWithContent, RevisionsAtomType } from "~/pageEditor/state"; import { createStateInitializer } from "./createStateInitializer"; import { PageEditorConfig } from "./config/PageEditorConfig"; +import elementVariableRendererPlugins from "~/editor/plugins/elementVariables"; interface PageDataAndRevisionsState { page: PageWithContent | null; @@ -46,7 +51,18 @@ const extractPageErrorData = (data: any): any => { return getPageData.error || {}; }; +const getBlocksWithUniqueElementIds = (blocks: PbEditorElement[]): PbEditorElement[] => { + return blocks?.map((block: PbEditorElement) => { + if (block.data?.blockId) { + return addElementId(block); + } else { + return block; + } + }); +}; + export const PageEditor: React.FC = () => { + plugins.register(elementVariableRendererPlugins()); const client = useApolloClient(); const { history, params } = useRouter(); const { showSnackbar } = useSnackbar(); @@ -76,13 +92,30 @@ export const PageEditor: React.FC = () => { data: {}, elements: [] }); - } else { - createBlockPlugin({ - ...element - }); } }); }); + const savedBLocks = client + .query({ query: LIST_PAGE_BLOCKS }) + .then(({ data }) => { + const blocks: PbPageBlock[] = get(data, "pageBuilder.listPageBlocks.data") || []; + blocks.forEach(element => { + createBlockPlugin({ + ...element + }); + }); + }); + const blockCategories = client + .query({ query: LIST_BLOCK_CATEGORIES }) + .then(({ data }) => { + const blockCategoriesData: PbBlockCategory[] = + get(data, "pageBuilder.listBlockCategories.data") || []; + blockCategoriesData.forEach(element => { + createBlockCategoryPlugin({ + ...element + }); + }); + }); const pageData = client .query({ @@ -102,9 +135,14 @@ export const PageEditor: React.FC = () => { } const { revisions = [], content, ...restOfPageData } = extractPageData(data); + + const existingContent = content + ? { ...content, elements: getBlocksWithUniqueElementIds(content.elements) } + : null; + const page: PageWithContent = { ...restOfPageData, - content: content || createElement("document") + content: existingContent || createElement("document") }; if (page.status === "draft") { @@ -137,7 +175,7 @@ export const PageEditor: React.FC = () => { }); return React.lazy(() => - Promise.all([savedElements, pageData]).then(() => { + Promise.all([savedElements, savedBLocks, blockCategories, pageData]).then(() => { return { default: ({ children }: { children: React.ReactElement }) => children }; }) ); diff --git a/packages/app-page-builder/src/pageEditor/config/BlockElementPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/BlockElementPlugin.tsx new file mode 100644 index 00000000000..de1d523f8c9 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/BlockElementPlugin.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from "react"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { ElementRoot, ElementRootChildrenFunction, DropZone } from "~/editor"; +import { useActiveElementId } from "~/editor/hooks/useActiveElementId"; +import { useCurrentBlockElement } from "~/editor/hooks/useCurrentBlockElement"; +import { useCurrentElement } from "~/editor/hooks/useCurrentElement"; + +/** + * Hook into `ElementRoot`, which is a component that renders _every_ element of the page content. + * If it's a `block` element, add an overlay to disable mouse interactions. + */ +const DisableInteractionsPlugin = createComponentPlugin(ElementRoot, Original => { + return function ElementRoot({ children, ...props }) { + const [, setActiveElementId] = useActiveElementId(); + const { element } = useCurrentElement(); + + const onClick = useCallback(() => { + setActiveElementId(element.id); + }, [element.id]); + + if (props.element.type !== "block") { + return {children}; + } + + if (!props.element.data?.blockId) { + return {children}; + } + + /** + * Block element uses the `render prop` version of `ElementRoot` children, so we only need to handle + * that scenario: packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx:25 + */ + return ( + + {params => ( + // @ts-ignore +
+ {(children as ElementRootChildrenFunction)(params)} +
+ )} +
+ ); + }; +}); + +const plugins = [DropZone.Below, DropZone.Above].map(Component => { + return createComponentPlugin(Component, Original => { + return function BlockDropZone({ children, ...props }) { + const { block } = useCurrentBlockElement(); + + if (!block) { + return {children}; + } + + if (block.data?.blockId) { + props.isVisible = () => { + return false; + }; + } + + return {children}; + }; + }); +}); + +export const BlockElementPlugin = () => { + return ( + <> + + {plugins.map((Plugin, index) => ( + + ))} + + ); +}; diff --git a/packages/app-page-builder/src/pageEditor/config/BlockElementSidebarPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/BlockElementSidebarPlugin.tsx new file mode 100644 index 00000000000..8817df11bd4 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/BlockElementSidebarPlugin.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { EditorSidebarTab } from "~/editor"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import UnlinkBlockAction from "~/pageEditor/plugins/elementSettings/UnlinkBlockAction"; +import { ReactComponent as InfoIcon } from "@webiny/app-admin/assets/icons/info.svg"; + +const UnlinkBlockWrapper = styled("div")({ + padding: "16px", + display: "grid", + rowGap: "16px", + justifyContent: "center", + alignItems: "center", + margin: "16px", + textAlign: "center", + backgroundColor: "var(--mdc-theme-background)", + border: "3px dashed var(--webiny-theme-color-border)", + borderRadius: "5px", + "& .info-wrapper": { + display: "flex", + alignItems: "center", + fontSize: "10px", + "& svg": { + width: "18px", + marginRight: "5px" + } + } +}); + +const UnlinkTab = () => { + return ( + + This is a block element - to change it you need to unlink it first. By unlinking it, any + changes made to the block will no longer automatically reflect to this page. +
+ + Unlink block + +
+
+ Click here to learn more about how block work +
+
+ ); +}; + +export const BlockElementSidebarPlugin = createComponentPlugin(EditorSidebarTab, Tab => { + return function ElementTab({ children, ...props }) { + const [element] = useActiveElement(); + + const isReferenceBlock = + element !== null && element.type === "block" && element.data?.blockId; + const isStyleTab = props?.label === "Style"; + + return {isReferenceBlock && isStyleTab ? : children}; + }; +}); diff --git a/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx new file mode 100644 index 00000000000..a931882106b --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { SidebarActions } from "~/editor"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import useElementSettings from "~/editor/plugins/elementSettings/hooks/useElementSettings"; +import VariableSettings from "~/editor/plugins/elementSettings/variable/VariableSettings"; + +export const ElementSettingsTabContentPlugin = createComponentPlugin( + SidebarActions, + SidebarActionsWrapper => { + return function SettingsTabContent({ children, ...props }) { + const [element] = useActiveElement(); + const elementSettings = useElementSettings(); + + const isReferenceBlockElement = element?.data?.blockId; + + return ( + <> + + {isReferenceBlockElement + ? elementSettings.map(({ plugin, options }, index) => { + return ( +
+ {typeof plugin.renderAction === "function" && + plugin.name !== + "pb-editor-page-element-settings-save" && + plugin.renderAction({ options })} +
+ ); + }) + : children} +
+ {isReferenceBlockElement && } + + ); + }; + } +); diff --git a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx index 3b7ec7da585..25bbc978a0c 100644 --- a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx +++ b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx @@ -1,7 +1,11 @@ import React from "react"; import { EventActionPlugins, EventActionHandlerPlugin } from "./eventActions"; +import { ImageContainerPlugin, ImagesListPlugin } from "./elements"; import { EditorBarPlugins } from "./editorBar"; import { BlockEditingPlugin } from "./blockEditing"; +import { BlockElementPlugin } from "./BlockElementPlugin"; +import { BlockElementSidebarPlugin } from "./BlockElementSidebarPlugin"; +import { ElementSettingsTabContentPlugin } from "./ElementSettingsTabContentPlugin"; export const PageEditorConfig = React.memo(() => { return ( @@ -10,6 +14,11 @@ export const PageEditorConfig = React.memo(() => { + + + + + ); }); diff --git a/packages/app-page-builder/src/pageEditor/config/blockEditing/BlockPreview.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/BlockPreview.tsx index 0c9396fc913..21dcfb9f931 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/BlockPreview.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/BlockPreview.tsx @@ -12,6 +12,7 @@ import * as Styled from "./StyledComponents"; import kebabCase from "lodash/kebabCase"; import { PbEditorBlockPlugin } from "~/types"; import { useCallback } from "react"; +import previewFallback from "~/admin/views/PageBlocks/assets/preview.png"; interface BlockPreviewProps { plugin: PbEditorBlockPlugin; @@ -77,7 +78,13 @@ const BlockPreview: React.FC = props => { )} - {plugin.preview()} + + {plugin?.image?.src ? ( + plugin.preview() + ) : ( + {plugin.title} + )} + {plugin.title} diff --git a/packages/app-page-builder/src/pageEditor/config/blockEditing/BlocksList.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/BlocksList.tsx index 3e4556b823d..6bf40144217 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/BlocksList.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/BlocksList.tsx @@ -129,7 +129,7 @@ const BlocksList: React.FC = props => { }} scrollTop={scrollTop} width={listWidth} - overscanRowCount={2} + overscanRowCount={10} />
diff --git a/packages/app-page-builder/src/pageEditor/config/blockEditing/EditBlockDialog.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/EditBlockDialog.tsx index d73fff39b30..6c3e939bf22 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/EditBlockDialog.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/EditBlockDialog.tsx @@ -55,7 +55,7 @@ const EditBlockDialog: React.FC = props => { "pb-editor-block-category" ); const blockCategoriesOptions = blockCategoryPlugins.map(item => ({ - value: item.name, + value: item.categoryName, label: item.title })); @@ -64,7 +64,7 @@ const EditBlockDialog: React.FC = props => { {loading && } {plugin && ( - {({ data, submit, Bind }) => ( + {({ submit, Bind }) => ( Update {plugin.title} @@ -78,24 +78,20 @@ const EditBlockDialog: React.FC = props => { - {data.type === "block" && ( - <> - - - - + + + {plugin.preview()} diff --git a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx index b680494a31d..cd6aa447d0a 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import classNames from "classnames"; import { useMutation } from "@apollo/react-hooks"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { plugins } from "@webiny/plugins"; import { OverlayLayout } from "@webiny/app-admin/components/OverlayLayout"; import { LeftPanel, RightPanel, SplitView } from "@webiny/app-admin/components/SplitView"; -import { List, ListItem, ListItemGraphic } from "@webiny/ui/List"; +import { ScrollList, ListItem, ListItemGraphic } from "@webiny/ui/List"; import { Icon } from "@webiny/ui/Icon"; import { Typography } from "@webiny/ui/Typography"; import { ReactComponent as SearchIcon } from "~/editor/assets/icons/search.svg"; @@ -18,9 +19,16 @@ import { useRecoilState, useRecoilValue } from "recoil"; import { ReactComponent as AllIcon } from "./icons/round-clear_all-24px.svg"; import createBlockPlugin from "~/admin/utils/createBlockPlugin"; import BlocksList from "./BlocksList"; -import { DELETE_PAGE_ELEMENT, UPDATE_PAGE_ELEMENT } from "./graphql"; +import { UPDATE_PAGE_BLOCK, DELETE_PAGE_BLOCK } from "~/admin/views/PageBlocks/graphql"; import EditBlockDialog from "./EditBlockDialog"; -import { listItem, ListItemTitle, listStyle, TitleContent } from "./SearchBlocksStyled"; +import { + IconWrapper, + listItem, + activeListItem, + ListItemTitle, + listStyle, + TitleContent +} from "./SearchBlocksStyled"; import * as Styled from "./StyledComponents"; import { PbEditorBlockCategoryPlugin, PbEditorBlockPlugin, PbEditorElement } from "~/types"; import { elementWithChildrenByIdSelector, rootElementAtom } from "~/editor/recoil/modules"; @@ -28,7 +36,8 @@ import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { useKeyHandler } from "~/editor/hooks/useKeyHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { createBlockElements } from "~/editor/helpers"; -import { DelayedOnChange } from "~/editor/components/DelayedOnChange"; +import { createBlockReference } from "~/pageEditor/helpers"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { blocksBrowserStateAtom } from "~/pageEditor/config/blockEditing/state"; const allBlockCategory: PbEditorBlockCategoryPlugin = { @@ -73,8 +82,8 @@ const SearchBar = () => { const [activeCategory, setActiveCategory] = useState("all"); const [updatePageElementMutation, { loading: updateInProgress }] = - useMutation(UPDATE_PAGE_ELEMENT); - const [deletePageElementMutation] = useMutation(DELETE_PAGE_ELEMENT); + useMutation(UPDATE_PAGE_BLOCK); + const [deletePageElementMutation] = useMutation(DELETE_PAGE_BLOCK); const allCategories = useMemo( () => [ @@ -103,9 +112,13 @@ const SearchBar = () => { const addBlockToContent = useCallback( plugin => { + const blockToAdd = + Object.keys(plugin.image).length === 0 + ? createBlockElements(plugin.name) + : createBlockReference(plugin.name); const element: any = { ...content, - elements: [...content.elements, createBlockElements(plugin.name)] + elements: [...content.elements, blockToAdd] }; eventActionHandler.trigger( new UpdateElementActionEvent({ @@ -139,7 +152,7 @@ const SearchBar = () => { }); } else { output = output.filter(item => { - return item.category === activeCategory; + return item.blockCategory === activeCategory; }); } } @@ -158,7 +171,7 @@ const SearchBar = () => { if (category === "all") { return allBlocks.length; } - return allBlocks.filter(pl => pl.category === category).length; + return allBlocks.filter(pl => pl.blockCategory === category).length; }, []); const { showSnackbar } = useSnackbar(); @@ -170,7 +183,7 @@ const SearchBar = () => { } }); - const { error } = response.data.pageBuilder.deletePageElement; + const { error } = response.data.pageBuilder.deletePageBlock; if (error) { showSnackbar(error.message); return; @@ -185,7 +198,7 @@ const SearchBar = () => { }, []); const updateBlock = useCallback( - async ({ updateElement, data: { title: name, category } }) => { + async ({ updateElement, data: { title: name, blockCategory } }) => { if (!editingBlock) { return; } @@ -193,11 +206,11 @@ const SearchBar = () => { const response = await updateElement({ variables: { id: editingBlock.id, - data: { name, category } + data: { name, blockCategory } } }); - const { error, data } = response.data.pageBuilder.updatePageElement; + const { error, data } = response.data.pageBuilder.pageBlock; if (error) { showSnackbar(error.message); return; @@ -251,11 +264,14 @@ const SearchBar = () => { - + {allCategories.map(p => ( { setActiveCategory(p.categoryName); }} @@ -271,14 +287,14 @@ const SearchBar = () => { ))} - + {activeCategory && ( {categoryPlugin.icon}} /> diff --git a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocksStyled.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocksStyled.tsx index 451533266f7..85cb68d4777 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocksStyled.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocksStyled.tsx @@ -5,15 +5,25 @@ import { Typography } from "@webiny/ui/Typography"; export const listStyle = css({ "&.mdc-list": { + height: "100%", padding: 0, backgroundColor: "var(--mdc-theme-surface)" } }); export const listItem = css({ - padding: "15px 20px", + padding: "15px 20px !important", cursor: "pointer", + overflow: "visible !important", borderBottom: "1px solid var(--mdc-theme-background)", + height: "auto !important", + transition: "background 0.3s", + "&::before": { + display: "none" + }, + "&::after": { + display: "none" + }, "&:last-child": { borderBottom: "none" }, @@ -22,6 +32,14 @@ export const listItem = css({ } }); +export const activeListItem = css({ + background: "var(--mdc-theme-background) !important" +}); + +export const IconWrapper = styled("div")({ + marginRight: 15 +}); + export const ListItemTitle = styled("div")({ fontWeight: 600, marginBottom: 5 diff --git a/packages/app-page-builder/src/pageEditor/config/editorBar/Title/Styled.ts b/packages/app-page-builder/src/pageEditor/config/editorBar/Title/Styled.ts index e4e133272af..af184283332 100644 --- a/packages/app-page-builder/src/pageEditor/config/editorBar/Title/Styled.ts +++ b/packages/app-page-builder/src/pageEditor/config/editorBar/Title/Styled.ts @@ -40,7 +40,7 @@ export const PageTitle = styled("div")({ }); export const pageTitleWrapper = css({ - maxWidth: "calc(100% - 50px)" + maxWidth: "calc(100% - 100px)" }); export const PageVersion = styled("span")({ diff --git a/packages/app-page-builder/src/pageEditor/config/elements/ImageContainerPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/elements/ImageContainerPlugin.tsx new file mode 100644 index 00000000000..25d8950d5c8 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/elements/ImageContainerPlugin.tsx @@ -0,0 +1,29 @@ +import React, { useMemo } from "react"; +import { ImageContainer } from "~/editor"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; + +export const ImageContainerPlugin = createComponentPlugin( + ImageContainer, + ImageContainerComponent => { + return function ImageContainerWithVariables({ element }) { + const variableValue = useElementVariableValue(element); + + const elementWithVariable = useMemo(() => { + if (variableValue) { + return { + ...element, + data: { + ...element.data, + image: { ...element.data.image, file: variableValue } + } + }; + } + + return element; + }, [element, variableValue]); + + return ; + }; + } +); diff --git a/packages/app-page-builder/src/pageEditor/config/elements/ImagesListPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/elements/ImagesListPlugin.tsx new file mode 100644 index 00000000000..227be2e6505 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/elements/ImagesListPlugin.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from "react"; +import { ImagesList } from "~/editor"; +import { createComponentPlugin } from "@webiny/app-admin"; +import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; + +export const ImagesListPlugin = createComponentPlugin(ImagesList, ImagesListComponent => { + return function ImageListWithVariables({ element }) { + const variableValue = useElementVariableValue(element); + + const elementWithVariable = useMemo(() => { + if (variableValue) { + return { + ...element, + data: { + ...element.data, + images: variableValue + } + }; + } + + return element; + }, [element, variableValue]); + + return ; + }; +}); diff --git a/packages/app-page-builder/src/pageEditor/config/elements/index.ts b/packages/app-page-builder/src/pageEditor/config/elements/index.ts new file mode 100644 index 00000000000..8d8ded81afb --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/elements/index.ts @@ -0,0 +1,2 @@ +export { ImageContainerPlugin } from "./ImageContainerPlugin"; +export { ImagesListPlugin } from "./ImagesListPlugin"; diff --git a/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerPlugin.tsx index 02aad8e964d..1c4d53bab70 100644 --- a/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerPlugin.tsx +++ b/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerPlugin.tsx @@ -9,6 +9,7 @@ import { usePage } from "~/pageEditor/hooks/usePage"; import { useRevisions } from "~/pageEditor/hooks/useRevisions"; import { PageAtomType, RevisionsAtomType } from "~/pageEditor/state"; import { PageEditorEventActionCallableState } from "~/pageEditor/types"; +import { PbElement, PbEditorElement } from "~/types"; type ProviderProps = EventActionHandlerProviderProps; @@ -26,6 +27,39 @@ const PbEventActionHandler = createComponentPlugin( revisionsAtomValueRef.current = revisionsAtomValue; }, [pageAtomValue, revisionsAtomValue]); + const getElementTree: ProviderProps["getElementTree"] = useMemo( + () => [ + ...(props.getElementTree || []), + next => { + return async props => { + const element = props?.element; + const res = (await next({ element })) as PbElement; + + const cleanUpReferenceBlocks = ( + element: PbElement + ): PbEditorElement => { + if (element.data.blockId) { + return { + ...element, + elements: [] + }; + } else { + return { + ...element, + elements: element.elements.map((child: PbElement) => + cleanUpReferenceBlocks(child) + ) + }; + } + }; + + return cleanUpReferenceBlocks(res); + }; + } + ], + [] + ); + const saveCallablesResults: ProviderProps["saveCallablesResults"] = useMemo( () => [ ...(props.saveCallablesResults || []), @@ -56,6 +90,7 @@ const PbEventActionHandler = createComponentPlugin( return ( diff --git a/packages/app-page-builder/src/pageEditor/helpers.ts b/packages/app-page-builder/src/pageEditor/helpers.ts new file mode 100644 index 00000000000..63f8013df64 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/helpers.ts @@ -0,0 +1,67 @@ +import invariant from "invariant"; +import { plugins } from "@webiny/plugins"; +import { getNanoid, addElementId } from "~/editor/helpers"; +import { + PbEditorBlockPlugin, + PbEditorElement, + PbElement, + PbBlockVariable, + PbEditorPageElementVariableRendererPlugin +} from "~/types"; + +export const createBlockReference = (name: string): PbEditorElement => { + const plugin = plugins.byName(name); + + invariant(plugin, `Missing block plugin "${name}"!`); + /** + * Used ts-ignore because TS is complaining about always overriding some properties + */ + + const blockElement = addElementId(plugin.create()); + return { + // @ts-ignore + id: getNanoid(), + // @ts-ignore + elements: [], + ...blockElement, + // @ts-ignore + data: { ...blockElement.data, blockId: plugin.id } + }; +}; + +/** + * Remove variableId from elements recursively + */ +export const removeElementVariableIds = ( + el: PbElement, + variables: PbBlockVariable[] +): PbElement => { + el.elements = el.elements.map(el => { + if (el.data?.variableId) { + const elementVariables = + variables.filter( + (variable: PbBlockVariable) => variable.id.split(".")[0] === el.data.variableId + ) || []; + const elementVariableRendererPlugins = + plugins.byType( + "pb-editor-page-element-variable-renderer" + ); + const elementVariablePlugin = elementVariableRendererPlugins.find( + plugin => plugin.elementType === el?.type + ); + + // we need to replace element value with the one from variables before removing variableId + el = elementVariablePlugin?.setElementValue(el, elementVariables) || el; + + // @ts-ignore + delete el.data?.variableId; + } + if (el.elements && el.elements.length) { + el = removeElementVariableIds(el, variables); + } + + return el; + }); + + return el; +}; diff --git a/packages/app-page-builder/src/editor/plugins/blocks/gridBlock/index.tsx b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx similarity index 70% rename from packages/app-page-builder/src/editor/plugins/blocks/gridBlock/index.tsx rename to packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx index 0b1d787ac99..ec629d1c261 100644 --- a/packages/app-page-builder/src/editor/plugins/blocks/gridBlock/index.tsx +++ b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx @@ -1,17 +1,17 @@ import React from "react"; -import preview from "./preview.png"; -import { createElement } from "../../../helpers"; +import { createElement } from "~/editor/helpers"; import { PbEditorBlockPlugin, PbEditorElement } from "~/types"; +import preview from "~/admin/views/PageBlocks/assets/preview.png"; -const width = 500; -const height = 73; +const width = 1000; +const height = 117; const aspectRatio = width / height; const plugin: PbEditorBlockPlugin = { - name: "pb-editor-grid-block", + name: "pb-editor-block-empty", type: "pb-editor-block", - category: "general", - title: "Grid block", + blockCategory: "general", + title: "Empty block", create(): PbEditorElement { return createElement("block", { elements: [createElement("grid")] diff --git a/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts b/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts new file mode 100644 index 00000000000..d10ba8d9dbd --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts @@ -0,0 +1,36 @@ +import React, { useCallback } from "react"; +import { cloneDeep } from "lodash"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; +import { removeElementVariableIds } from "~/pageEditor/helpers"; +import { PbElement } from "~/types"; + +interface UnlinkBlockActionPropsType { + children: React.ReactElement; +} +const UnlinkBlockAction: React.FC = ({ children }) => { + const [element] = useActiveElement(); + const { getElementTree } = useEventActionHandler(); + const updateElement = useUpdateElement(); + + const onClick = useCallback(async (): Promise => { + if (element) { + // we need to drop blockId and variables properties when unlinking, so they are separated from all other element data + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { blockId, variables, ...newData } = element.data; + const pbElement = (await getElementTree({ + element: { ...element, data: newData } + })) as PbElement; + // we make copy of element to delete variableIds from it + const elementCopy = cloneDeep(pbElement); + const elementWithoutVariableIds = removeElementVariableIds(elementCopy, variables); + + updateElement(elementWithoutVariableIds); + } + }, [element, updateElement]); + + return React.cloneElement(children, { onClick }); +}; + +export default React.memo(UnlinkBlockAction); diff --git a/packages/app-page-builder/src/render/components/ElementRoot.tsx b/packages/app-page-builder/src/render/components/ElementRoot.tsx index 79cf49ec29e..2ea7210401d 100644 --- a/packages/app-page-builder/src/render/components/ElementRoot.tsx +++ b/packages/app-page-builder/src/render/components/ElementRoot.tsx @@ -8,22 +8,26 @@ import { PbEditorElement } from "~/types"; import { usePageBuilder } from "~/hooks/usePageBuilder"; +import { makeComposable } from "@webiny/react-composition"; type CombineClassNamesType = (...styles: string[]) => string; const combineClassNames: CombineClassNamesType = (...styles) => { return styles.filter(s => s !== "" && s !== "css-0").join(" "); }; -interface ElementRootChildrenFunctionParamsType { +export interface ElementRootChildrenFunctionParamsType { getAllClasses: (...classes: string[]) => string; combineClassNames: (...classes: string[]) => string; elementStyle: CSSProperties; elementAttributes: { [key: string]: string }; customClasses: string[]; } -type ElementRootChildrenFunction = (params: ElementRootChildrenFunctionParamsType) => ReactElement; -interface ElementRootProps { +export interface ElementRootChildrenFunction { + (params: ElementRootChildrenFunctionParamsType): ReactElement; +} + +export interface ElementRootProps { element: PbElement | PbEditorElement; style?: CSSProperties; className?: string; @@ -108,4 +112,4 @@ const ElementRootComponent: React.FC = ({ ); }; -export const ElementRoot = React.memo(ElementRootComponent); +export const ElementRoot = makeComposable("ElementRoot", ElementRootComponent); diff --git a/packages/app-page-builder/src/render/components/Text.tsx b/packages/app-page-builder/src/render/components/Text.tsx index f589c229752..e665723bb2d 100644 --- a/packages/app-page-builder/src/render/components/Text.tsx +++ b/packages/app-page-builder/src/render/components/Text.tsx @@ -32,7 +32,7 @@ const TextElement: React.FC = ({ element, rootClassName }) => { return ( - {React.createElement(tag, { + {React.createElement(tag === "p" ? "div" : tag, { dangerouslySetInnerHTML: { __html: textContent } })} diff --git a/packages/app-page-builder/src/render/index.ts b/packages/app-page-builder/src/render/index.ts new file mode 100644 index 00000000000..9536c0052a3 --- /dev/null +++ b/packages/app-page-builder/src/render/index.ts @@ -0,0 +1 @@ +export * from "./components/ElementRoot"; diff --git a/packages/app-page-builder/src/render/plugins/elementSettings/grid/index.ts b/packages/app-page-builder/src/render/plugins/elementSettings/grid/index.ts new file mode 100644 index 00000000000..3d55f372a78 --- /dev/null +++ b/packages/app-page-builder/src/render/plugins/elementSettings/grid/index.ts @@ -0,0 +1,30 @@ +import { get } from "lodash"; +import kebabCase from "lodash/kebabCase"; +import { PbRenderElementStylePlugin } from "~/types"; +import { applyPerDeviceStyleWithFallback } from "../../../utils"; + +export default { + name: "pb-render-page-element-style-grid", + type: "pb-render-page-element-style", + renderStyle({ element, style }) { + const gridSettings = get(element, "data.settings.gridSettings"); + + // Set per-device property value + applyPerDeviceStyleWithFallback(({ displayMode, fallbackMode }) => { + const fallbackValue = get( + style, + `--${kebabCase(fallbackMode)}-flex-direction`, + "unset" + ); + const flexDirection = get(gridSettings, `${displayMode}.flexDirection`, fallbackValue); + + style[`--${kebabCase(displayMode)}-flex-direction`] = flexDirection; + + if (flexDirection === "column" || flexDirection === "column-reverse") { + style[`--${kebabCase(displayMode)}-cell-width`] = "100%"; + } + }); + + return style; + } +} as PbRenderElementStylePlugin; diff --git a/packages/app-page-builder/src/render/plugins/elementSettings/margin/index.ts b/packages/app-page-builder/src/render/plugins/elementSettings/margin/index.ts index b3cfef21cfa..b9d34405d0c 100644 --- a/packages/app-page-builder/src/render/plugins/elementSettings/margin/index.ts +++ b/packages/app-page-builder/src/render/plugins/elementSettings/margin/index.ts @@ -4,13 +4,15 @@ import { PbRenderElementStylePlugin } from "../../../../types"; import { applyPerDeviceStyleWithFallback } from "../../../utils"; const validateSpacingValue = (value: string): string | "auto" => { - if (!value) { - return "0px"; - } - if (value.includes("auto")) { + if (value?.includes("auto")) { return "auto"; } + const parsedValue = parseInt(value); + if (Number.isNaN(parsedValue)) { + return "0px"; + } + return value; }; diff --git a/packages/app-page-builder/src/render/plugins/elementSettings/shadow/index.ts b/packages/app-page-builder/src/render/plugins/elementSettings/shadow/index.ts index 2a2a5624c55..b24365e6f13 100644 --- a/packages/app-page-builder/src/render/plugins/elementSettings/shadow/index.ts +++ b/packages/app-page-builder/src/render/plugins/elementSettings/shadow/index.ts @@ -7,12 +7,15 @@ export default { renderStyle({ element, style }) { const { shadow } = get(element, "data.settings", {}); if (!shadow) { - return style; + return { + ...style, + "--box-shadow": "none" + }; } return { ...style, - boxShadow: [ + "--box-shadow": [ (shadow.horizontal || 0) + "px", (shadow.vertical || 0) + "px", (shadow.blur || 0) + "px", diff --git a/packages/app-page-builder/src/render/plugins/elements/block/Block.tsx b/packages/app-page-builder/src/render/plugins/elements/block/Block.tsx index f08ef71c3c4..248376b5eda 100644 --- a/packages/app-page-builder/src/render/plugins/elements/block/Block.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/block/Block.tsx @@ -1,12 +1,26 @@ import React, { CSSProperties } from "react"; import { css } from "emotion"; import kebabCase from "lodash/kebabCase"; -import Element from "../../../components/Element"; -import { ElementRoot } from "../../../components/ElementRoot"; +import Element from "~/render/components/Element"; +import { ElementRoot } from "~/render/components/ElementRoot"; import { PbElement } from "~/types"; -import ElementAnimation from "../../../components/ElementAnimation"; +import ElementAnimation from "~/render/components/ElementAnimation"; import { Interpolation } from "@emotion/core"; -import { PageBuilderContext } from "../../../../contexts/PageBuilder"; +import { PageBuilderContext } from "~/contexts/PageBuilder"; + +// TODO: move to a declaration file +declare global { + // eslint-disable-next-line + namespace JSX { + interface IntrinsicElements { + // @ts-ignore + "ps-tag": { + key?: string; + value?: string; + }; + } + } +} interface BlockProps { element: PbElement; @@ -15,67 +29,71 @@ const Block: React.FC = ({ element }) => { const { responsiveDisplayMode: { displayMode } } = React.useContext(PageBuilderContext); + return ( - - - {({ elementStyle, elementAttributes, customClasses, combineClassNames }) => { - const containerStyle = elementStyle; - // Use per-device style - const width = - elementStyle[ - `--${kebabCase( - displayMode - )}-align-items` as unknown as keyof CSSProperties - ]; - /** - * We're swapping "justifyContent" & "alignItems" value here because - * ".webiny-pb-layout-block" has "flex-direction: column" - */ - const alignItems = - elementStyle[ - `--${kebabCase( - displayMode - )}-justify-content` as unknown as keyof CSSProperties - ]; - const justifyContent = - elementStyle[ - `--${kebabCase( - displayMode - )}-align-items` as unknown as keyof CSSProperties - ]; + <> + + + + {({ elementStyle, elementAttributes, customClasses, combineClassNames }) => { + const containerStyle = elementStyle; + // Use per-device style + const width = + elementStyle[ + `--${kebabCase( + displayMode + )}-align-items` as unknown as keyof CSSProperties + ]; + /** + * We're swapping "justifyContent" & "alignItems" value here because + * ".webiny-pb-layout-block" has "flex-direction: column" + */ + const alignItems = + elementStyle[ + `--${kebabCase( + displayMode + )}-justify-content` as unknown as keyof CSSProperties + ]; + const justifyContent = + elementStyle[ + `--${kebabCase( + displayMode + )}-align-items` as unknown as keyof CSSProperties + ]; - // TODO @ts-refactor style type - return ( -
+ // TODO @ts-refactor style type + return (
- {element.elements.map(element => ( - - ))} +
+ {element.elements.map(element => ( + + ))} +
-
- ); - }} -
-
+ ); + }} +
+
+ ); }; diff --git a/packages/app-page-builder/src/render/plugins/elements/button/Button.tsx b/packages/app-page-builder/src/render/plugins/elements/button/Button.tsx index 290e9e85517..6219128782d 100644 --- a/packages/app-page-builder/src/render/plugins/elements/button/Button.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/button/Button.tsx @@ -6,6 +6,21 @@ import { PbButtonElementClickHandlerPlugin, PbElement, PbElementDataType } from import { Link } from "@webiny/react-router"; import { PageBuilderContext } from "~/contexts/PageBuilder"; +const formatUrl = (url: string): string => { + // Check if external domain url (e.g. google.com, https://www.google.com) + const isExternalUrl = new RegExp(/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}.*?/gi).test( + url + ); + const isStartingWithHttp = url.startsWith("http://") || url.startsWith("https://"); + + // If external domain url, but without protocol we add it manually + if (isExternalUrl && !isStartingWithHttp) { + url = "https://" + url; + } + + return url; +}; + interface ElementData extends Omit { action?: Partial; } @@ -40,7 +55,7 @@ const Button: React.FC = ({ element }) => { href = link?.href; newTab = !!link?.newTab; } else { - href = action.href as string; + href = action?.href ? formatUrl(action.href) : ""; newTab = action.newTab || false; } diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index de679bce1df..e8b5059e44d 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -206,6 +206,28 @@ export interface PbElement { text?: string; } +export interface PbBlockVariable { + id: string; + type: string; + label: string; + value: TValue; +} + +export type PbBlockEditorCreateVariablePlugin = Plugin & { + type: "pb-block-editor-create-variable"; + elementType: string; + createVariables: (params: { element: PbEditorElement }) => PbBlockVariable[]; + getVariableValue: (params: { element: PbEditorElement; variableId?: string }) => any; +}; + +export type PbEditorPageElementVariableRendererPlugin = Plugin & { + type: "pb-editor-page-element-variable-renderer"; + elementType: string; + getVariableValue: (element: PbEditorElement | null) => any; + renderVariableInput: (variableId: string) => ReactNode; + setElementValue: (element: PbElement, variables: PbBlockVariable[]) => PbElement; +}; + /** * Determine types for elements */ @@ -554,7 +576,7 @@ export type PbEditorBlockPlugin = Plugin & { id?: string; type: "pb-editor-block"; title: string; - category: string; + blockCategory: string; tags: string[]; image: { src?: string; @@ -688,11 +710,16 @@ export type PbRenderElementPluginArgs = { elementType?: string; }; +export type GetElementTreeProps = { + element?: PbEditorElement; + path?: string[]; +} | void; + // ============== EVENT ACTION HANDLER ================= // // TODO: at some point, convert this into an interface, and use module augmentation to add new properties. export type EventActionHandlerCallableState = PbState & { getElementById(id: string): Promise; - getElementTree(element?: PbEditorElement): Promise; + getElementTree(props: GetElementTreeProps): Promise; }; export interface EventActionHandler { @@ -709,7 +736,7 @@ export interface EventActionHandler { endBatch: () => void; enableHistory: () => void; disableHistory: () => void; - getElementTree: (element?: PbEditorElement) => Promise; + getElementTree: (props: GetElementTreeProps) => Promise; } export interface EventActionHandlerTarget { @@ -774,6 +801,33 @@ export interface PbMenu { slug: string; description: string; } + +export interface PbBlockCategory { + name: string; + slug: string; + icon: string; + description: string; + createdOn: string; + createdBy: PbIdentity; +} + +export interface PbPageBlock { + id: string; + name: string; + blockCategory: string; + content: any; + preview: { + src: string; + meta: { + width: number; + height: number; + aspectRatio: number; + }; + }; + createdOn: string; + createdBy: PbIdentity; +} + /** * TODO: have types for both API and app in the same package? * GraphQL response types diff --git a/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/editorPlugins.ts b/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/editorPlugins.ts index 0a9c794d49b..f877c6c44ae 100644 --- a/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/editorPlugins.ts +++ b/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/editorPlugins.ts @@ -9,30 +9,25 @@ import list from "@webiny/app-page-builder/editor/plugins/elements/list"; import quote from "@webiny/app-page-builder/editor/plugins/elements/quote"; import icon from "@webiny/app-page-builder/editor/plugins/elements/icon"; import button from "@webiny/app-page-builder/editor/plugins/elements/button"; -import soundcloud from "@webiny/app-page-builder/editor/plugins/elements/media/soundcloud"; -import vimeo from "@webiny/app-page-builder/editor/plugins/elements/media/vimeo"; -import youtube from "@webiny/app-page-builder/editor/plugins/elements/media/youtube"; +import soundcloud from "@webiny/app-page-builder/editor/plugins/elements/embeds/soundcloud"; +import vimeo from "@webiny/app-page-builder/editor/plugins/elements/embeds/vimeo"; +import youtube from "@webiny/app-page-builder/editor/plugins/elements/embeds/youtube"; import pinterest from "@webiny/app-page-builder/editor/plugins/elements/social/pinterest"; import twitter from "@webiny/app-page-builder/editor/plugins/elements/social/twitter"; import codesandbox from "@webiny/app-page-builder/editor/plugins/elements/code/codesandbox"; -import iframe from "@webiny/app-page-builder/editor/plugins/elements/media/iframe"; +import iframe from "@webiny/app-page-builder/editor/plugins/elements/embeds/iframe"; import pagesList from "@webiny/app-page-builder/editor/plugins/elements/pagesList"; import imagesList from "@webiny/app-page-builder/editor/plugins/elements/imagesList"; import heading from "@webiny/app-page-builder/editor/plugins/elements/heading"; -// Icons -import icons from "@webiny/app-page-builder/editor/plugins/icons"; // Element groups import basicGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/basic"; import layoutGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/layout"; import mediaGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/media"; +import embedsGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/embeds"; import formGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/form"; import socialGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/social"; import codeGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/code"; import savedGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/saved"; -// Blocks -import gridBlock from "@webiny/app-page-builder/editor/plugins/blocks/gridBlock"; -// Block categories -import blocksCategories from "@webiny/app-page-builder/editor/plugins/blocksCategories"; // Toolbar import addElement from "@webiny/app-page-builder/editor/plugins/toolbar/addElement"; import navigator from "@webiny/app-page-builder/editor/plugins/toolbar/navigator"; @@ -68,7 +63,6 @@ export default [ document(), grid(), block(), - gridBlock, cell(), heading(), paragraph(), @@ -88,18 +82,15 @@ export default [ pagesList(), // grid presets ...gridPresets, - // Icons - icons, // Element groups basicGroup, formGroup, layoutGroup, mediaGroup, + embedsGroup, socialGroup, codeGroup, savedGroup, - // Block categories - blocksCategories, // Toolbar addElement, navigator(), diff --git a/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/renderPlugins.ts b/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/renderPlugins.ts index 778eb5b2741..bbb78ad0e55 100644 --- a/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/renderPlugins.ts +++ b/packages/cwp-template-aws/template/common/apps/admin/src/plugins/pageBuilder/renderPlugins.ts @@ -25,6 +25,7 @@ import align from "@webiny/app-page-builder/render/plugins/elementSettings/align import animation from "@webiny/app-page-builder/render/plugins/elementSettings/animation"; import background from "@webiny/app-page-builder/render/plugins/elementSettings/background"; import border from "@webiny/app-page-builder/render/plugins/elementSettings/border"; +import gridSettings from "@webiny/app-page-builder/render/plugins/elementSettings/grid"; import height from "@webiny/app-page-builder/render/plugins/elementSettings/height"; import width from "@webiny/app-page-builder/render/plugins/elementSettings/width"; import shadow from "@webiny/app-page-builder/render/plugins/elementSettings/shadow"; @@ -63,6 +64,7 @@ export default [ animation, background, border, + gridSettings, height, width, shadow, diff --git a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/components/Menu.tsx b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/components/Menu.tsx index 548a754391a..1f5f5b7dd8e 100644 --- a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/components/Menu.tsx +++ b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/components/Menu.tsx @@ -19,7 +19,7 @@ declare global { export const hasMenuItems = (data: GetPublishMenuQueryResponse): boolean => { return Boolean( data && - Array.isArray(data.pageBuilder.getPublicMenu.data.items) && + Array.isArray(data.pageBuilder.getPublicMenu.data?.items) && data.pageBuilder.getPublicMenu.data.items.length ); }; diff --git a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/base.scss b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/base.scss index 9c939001d92..43e523c7a07 100644 --- a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/base.scss +++ b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/base.scss @@ -74,6 +74,8 @@ justify-content: var(--desktop-justify-content); align-items: var(--desktop-align-items); + + box-shadow: var(--box-shadow); } // Some styles need more specificity .webiny-pb-media-query--desktop .webiny-pb-base-page-element-style { diff --git a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/image.scss b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/image.scss index f2b28db2928..316dc5cf300 100644 --- a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/image.scss +++ b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/image.scss @@ -9,11 +9,12 @@ img { } .webiny-pb-page-element-image { - overflow: hidden; box-sizing: border-box; + box-shadow: none !important; //display: flex; removed because of testimonial #2 img { max-width: 100%; + box-shadow: var(--box-shadow); } } // make images center align on mobile diff --git a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/layout.scss b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/layout.scss index 692db1a8d78..01e5a170a69 100644 --- a/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/layout.scss +++ b/packages/cwp-template-aws/template/common/apps/theme/pageBuilder/styles/elements/layout.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + .webiny-pb-layout-block-container { } .webiny-pb-media-query--mobile-landscape .webiny-pb-layout-block-container { @@ -25,72 +27,49 @@ .webiny-pb-layout-grid { width: 100%; display: flex; - flex-direction: row; +} - &-cell { +.webiny-pb-media-query--desktop .webiny-pb-layout-grid { + flex-direction: var(--desktop-flex-direction, "row"); + &-cell { @for $current from 1 to 13 { &-#{$current} { - width: percentage((1 / 12) * $current); + width: var(--desktop-cell-width, math.percentage(math.div(1, 12) * $current)); } } - - & img { - width: auto; - height: auto; - } } } - -.webiny-pb-media-query--desktop .webiny-pb-layout-grid { - flex-direction: row; - // flex: 100% 1; // removed because Content #2 -} .webiny-pb-media-query--tablet .webiny-pb-layout-grid { - flex-direction: row; - // flex: 100% 1; // removed because Content #2 -} -.webiny-pb-media-query--mobile-landscape .webiny-pb-layout-grid { - flex-direction: column; - max-width: 100% !important; - //width: 100% !important; // added for Content #6 - //width: auto !important; // set to auto for CTA #9 - flex: 0 auto !important; + flex-direction: var(--tablet-flex-direction, "row"); &-cell { - flex-grow: 1; - @for $current from 1 to 13 { &-#{$current} { - width: 100%; + width: var(--tablet-cell-width, math.percentage(math.div(1, 12) * $current)); } } + } +} +.webiny-pb-media-query--mobile-landscape .webiny-pb-layout-grid { + flex-direction: var(--mobile-landscape-flex-direction, "column"); - & img { - width: auto; - height: auto; + &-cell { + @for $current from 1 to 13 { + &-#{$current} { + width: var(--mobile-landscape-cell-width, math.percentage(math.div(1, 12) * $current)); + } } } } .webiny-pb-media-query--mobile-portrait .webiny-pb-layout-grid { - flex-direction: column; - max-width: 100% !important; - //width: 100% !important; // added for Content #6 - //width: auto !important; // set to auto for CTA #9 - flex: 0 auto !important; + flex-direction: var(--mobile-portrait-flex-direction, "column"); &-cell { - flex-grow: 1; - @for $current from 1 to 13 { &-#{$current} { - width: 100%; + width: var(--mobile-portrait-cell-width, math.percentage(math.div(1, 12) * $current)); } } - - & img { - width: auto; - height: auto; - } } } diff --git a/packages/cwp-template-aws/template/common/apps/website/src/plugins/pageBuilder.ts b/packages/cwp-template-aws/template/common/apps/website/src/plugins/pageBuilder.ts index bd4a7fdb1dc..4c84eeca438 100644 --- a/packages/cwp-template-aws/template/common/apps/website/src/plugins/pageBuilder.ts +++ b/packages/cwp-template-aws/template/common/apps/website/src/plugins/pageBuilder.ts @@ -44,6 +44,7 @@ import align from "@webiny/app-page-builder/render/plugins/elementSettings/align import animation from "@webiny/app-page-builder/render/plugins/elementSettings/animation"; import background from "@webiny/app-page-builder/render/plugins/elementSettings/background"; import border from "@webiny/app-page-builder/render/plugins/elementSettings/border"; +import gridSettings from "@webiny/app-page-builder/render/plugins/elementSettings/grid"; import height from "@webiny/app-page-builder/render/plugins/elementSettings/height"; import width from "@webiny/app-page-builder/render/plugins/elementSettings/width"; import shadow from "@webiny/app-page-builder/render/plugins/elementSettings/shadow"; @@ -91,6 +92,7 @@ export default [ animation, background, border, + gridSettings, height, width, shadow, diff --git a/packages/ui/src/Button/Button.tsx b/packages/ui/src/Button/Button.tsx index 89672bc032d..2bc60110312 100644 --- a/packages/ui/src/Button/Button.tsx +++ b/packages/ui/src/Button/Button.tsx @@ -131,10 +131,10 @@ export type ButtonFloatingProps = ButtonProps & export const ButtonFloating: React.FC = props => { const { disabled, + label, icon, onClick, small = false, - label = false, ripple = true, className = null, ...rest diff --git a/packages/app-page-builder/src/editor/components/DelayedOnChange/DelayedOnChange.ts b/packages/ui/src/DelayedOnChange/DelayedOnChange.ts similarity index 100% rename from packages/app-page-builder/src/editor/components/DelayedOnChange/DelayedOnChange.ts rename to packages/ui/src/DelayedOnChange/DelayedOnChange.ts diff --git a/packages/app-page-builder/src/editor/components/DelayedOnChange/index.ts b/packages/ui/src/DelayedOnChange/index.ts similarity index 100% rename from packages/app-page-builder/src/editor/components/DelayedOnChange/index.ts rename to packages/ui/src/DelayedOnChange/index.ts diff --git a/packages/app-page-builder/src/editor/components/DelayedOnChange/withDelayedOnChange.tsx b/packages/ui/src/DelayedOnChange/withDelayedOnChange.tsx similarity index 100% rename from packages/app-page-builder/src/editor/components/DelayedOnChange/withDelayedOnChange.tsx rename to packages/ui/src/DelayedOnChange/withDelayedOnChange.tsx diff --git a/packages/validation/__tests__/slug.test.js b/packages/validation/__tests__/slug.test.js new file mode 100644 index 00000000000..f5fc38539e4 --- /dev/null +++ b/packages/validation/__tests__/slug.test.js @@ -0,0 +1,71 @@ +import { validation, ValidationError } from "../src"; + +describe("slug test", () => { + it("should not get triggered if empty value was set", async () => { + await expect(validation.validate(null, "slug")).resolves.toBe(true); + }); + + it("should not get triggered if correct value was set", async () => { + //await expect(validation.validate("test-slug-correct", "slug")).resolves.toBe(true); + await expect(validation.validate("test-slug", "slug")).resolves.toBe(true); + await expect(validation.validate("test", "slug")).resolves.toBe(true); + }); + + it("should fail - wrong dash character usage", async () => { + await expect(validation.validate("test--slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("test---slug", "slug")).rejects.toThrow(ValidationError); + + await expect(validation.validate("-test-slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("test-slug-", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("--slug--", "slug")).rejects.toThrow(ValidationError); + + await expect(validation.validate("-slug-", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("-slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("slug-", "slug")).rejects.toThrow(ValidationError); + }); + + it("should fail - uppercase letters are not allowed", async () => { + await expect(validation.validate("Test-Slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("test-Slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("Test-slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("tesT-sluG", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("Test", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("tEst", "slug")).rejects.toThrow(ValidationError); + }); + + it("should fail - numbers are not allowed", async () => { + await expect(validation.validate("test-slug-12345", "slug")).rejects.toThrow( + ValidationError + ); + await expect(validation.validate("test-12345-slug", "slug")).rejects.toThrow( + ValidationError + ); + await expect(validation.validate("12345-test-slug", "slug")).rejects.toThrow( + ValidationError + ); + await expect(validation.validate("test123-slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("test-slug123", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("12345-slug", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("slug-12345", "slug")).rejects.toThrow(ValidationError); + + await expect(validation.validate("slug12345", "slug")).rejects.toThrow(ValidationError); + }); + + it("should fail - special chars are not allowed", async () => { + await expect(validation.validate("test-slug-&%^#", "slug")).rejects.toThrow( + ValidationError + ); + + await expect(validation.validate("test-&%^#-slug", "slug")).rejects.toThrow( + ValidationError + ); + await expect(validation.validate("&%^#-test-slug", "slug")).rejects.toThrow( + ValidationError + ); + + await expect(validation.validate("&%^#-test", "slug")).rejects.toThrow(ValidationError); + await expect(validation.validate("test-&%^#", "slug")).rejects.toThrow(ValidationError); + + await expect(validation.validate("test&%^#", "slug")).rejects.toThrow(ValidationError); + }); +}); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index fa248ae9ccc..17779198405 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -22,6 +22,7 @@ import dateGte from "./validators/dateGte"; import dateLte from "./validators/dateLte"; import timeGte from "./validators/timeGte"; import timeLte from "./validators/timeLte"; +import slug from "./validators/slug"; const validation = new Validation(); validation.setValidator("creditCard", creditCard); @@ -46,5 +47,6 @@ validation.setValidator("dateGte", dateGte); validation.setValidator("dateLte", dateLte); validation.setValidator("timeGte", timeGte); validation.setValidator("timeLte", timeLte); +validation.setValidator("slug", slug); export { validation, Validation, ValidationError }; diff --git a/packages/validation/src/validators/slug.ts b/packages/validation/src/validators/slug.ts new file mode 100644 index 00000000000..ba6d1d51c62 --- /dev/null +++ b/packages/validation/src/validators/slug.ts @@ -0,0 +1,16 @@ +import ValidationError from "~/validationError"; + +export default (value: any) => { + if (!value) { + return; + } + value = value + ""; + + if (value.match(/^[a-z]+(\-[a-z]+)*$/) && value.length <= 100) { + return; + } + + throw new ValidationError( + "Slug must consist of only 'a-z' and '-' and be max 100 characters long (for example: 'some-entry-slug')" + ); +};