From 636f35576fafa565e780194fe65081947aa538db Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Tue, 10 May 2022 13:25:24 +0300 Subject: [PATCH 01/56] feat: add Block Categories entity to Page Builder app --- .../src/definitions/blockCategoryEntity.ts | 46 +++ .../api-page-builder-so-ddb-es/src/index.ts | 50 ++- .../operations/blockCategory/dataLoader.ts | 74 +++++ .../src/operations/blockCategory/fields.ts | 25 ++ .../src/operations/blockCategory/index.ts | 214 +++++++++++++ .../src/operations/blockCategory/keys.ts | 16 + ...BlockCategoryDynamoDbElasticFieldPlugin.ts | 5 + .../api-page-builder-so-ddb-es/src/types.ts | 12 +- .../src/definitions/blockCategoryEntity.ts | 46 +++ packages/api-page-builder-so-ddb/src/index.ts | 44 ++- .../operations/blockCategory/dataLoader.ts | 74 +++++ .../src/operations/blockCategory/fields.ts | 25 ++ .../src/operations/blockCategory/index.ts | 214 +++++++++++++ .../src/operations/blockCategory/keys.ts | 16 + .../BlockCategoryDynamoDbFieldPlugin.ts | 5 + packages/api-page-builder-so-ddb/src/types.ts | 11 +- packages/api-page-builder/src/graphql/crud.ts | 11 +- .../src/graphql/crud/blockCategories.crud.ts | 303 ++++++++++++++++++ .../api-page-builder/src/graphql/graphql.ts | 3 + .../graphql/graphql/blockCategories.gql.ts | 86 +++++ .../api-page-builder/src/graphql/types.ts | 66 ++++ packages/api-page-builder/src/types.ts | 93 ++++++ packages/app-page-builder/src/PageBuilder.tsx | 11 +- .../PageBuilderPermissions.tsx | 10 +- .../src/admin/plugins/routes.tsx | 20 ++ .../views/BlockCategories/BlockCategories.tsx | 38 +++ .../BlockCategoriesDataList.tsx | 231 +++++++++++++ .../BlockCategories/BlockCategoriesForm.tsx | 243 ++++++++++++++ .../admin/views/BlockCategories/graphql.ts | 143 +++++++++ packages/app-page-builder/src/types.ts | 8 + 30 files changed, 2112 insertions(+), 31 deletions(-) create mode 100644 packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/blockCategory/fields.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/blockCategory/index.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/blockCategory/keys.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/plugins/definitions/BlockCategoryDynamoDbElasticFieldPlugin.ts create mode 100644 packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/blockCategory/fields.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/blockCategory/index.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/blockCategory/keys.ts create mode 100644 packages/api-page-builder-so-ddb/src/plugins/definitions/BlockCategoryDynamoDbFieldPlugin.ts create mode 100644 packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts create mode 100644 packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/BlockCategories.tsx create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts 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..4f130991fda --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts @@ -0,0 +1,46 @@ +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" + }, + 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..f0e317921bb 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,42 @@ 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"; export const createStorageOperations: StorageOperationsFactory = params => { const { @@ -82,7 +93,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Built-in Elasticsearch index templates */ - elasticsearchIndexPlugins() + elasticsearchIndexPlugins(), + /** + * Block Category fields required for filtering/sorting. + */ + createBlockCategoryDynamoDbFields() ]); const entities = { @@ -120,6 +135,11 @@ 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] : {} }) }; @@ -160,6 +180,10 @@ export const createStorageOperations: StorageOperationsFactory = params => { esEntity: entities.pagesEs, elasticsearch, plugins + }), + blockCategories: createBlockCategoryStorageOperations({ + entity: entities.blockCategories, + 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/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/types.ts b/packages/api-page-builder-so-ddb-es/src/types.ts index d27da1c982d..94111c02dfe 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,8 @@ export enum ENTITIES { MENUS = "PbMenus", PAGE_ELEMENTS = "PbPageElements", PAGES = "PbPages", - PAGES_ES = "PbPagesEs" + PAGES_ES = "PbPagesEs", + BLOCK_CATEGORIES = "PbBlockCategories" } export interface TableModifier { @@ -31,7 +32,14 @@ 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", 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..4f130991fda --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts @@ -0,0 +1,46 @@ +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" + }, + 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..3fcfa127fff 100644 --- a/packages/api-page-builder-so-ddb/src/index.ts +++ b/packages/api-page-builder-so-ddb/src/index.ts @@ -1,24 +1,35 @@ 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"; + export const createStorageOperations: StorageOperationsFactory = params => { const { documentClient, table, attributes, plugins: userPlugins } = params; @@ -51,7 +62,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Page fields required for filtering/sorting. */ - createPageFields() + createPageFields(), + /** + * Block Category fields required for filtering/sorting. + */ + createBlockCategoryDynamoDbFields() ]); const entities = { @@ -84,6 +99,11 @@ 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] : {} }) }; @@ -111,6 +131,10 @@ export const createStorageOperations: StorageOperationsFactory = params => { pages: createPageStorageOperations({ entity: entities.pages, plugins + }), + blockCategories: createBlockCategoryStorageOperations({ + entity: entities.blockCategories, + 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/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/types.ts b/packages/api-page-builder-so-ddb/src/types.ts index 214329f4ca3..9f107813dd4 100644 --- a/packages/api-page-builder-so-ddb/src/types.ts +++ b/packages/api-page-builder-so-ddb/src/types.ts @@ -18,7 +18,8 @@ export enum ENTITIES { CATEGORIES = "PbCategories", MENUS = "PbMenus", PAGE_ELEMENTS = "PbPageElements", - PAGES = "PbPages" + PAGES = "PbPages", + BLOCK_CATEGORIES = "PbBlockCategories" } export interface TableModifier { @@ -28,7 +29,13 @@ 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", Entity >; } diff --git a/packages/api-page-builder/src/graphql/crud.ts b/packages/api-page-builder/src/graphql/crud.ts index 855f204c15e..cf6a9e3d4d0 100644 --- a/packages/api-page-builder/src/graphql/crud.ts +++ b/packages/api-page-builder/src/graphql/crud.ts @@ -1,4 +1,5 @@ import { createMenuCrud } from "./crud/menus.crud"; +import { createBlockCategoriesCrud } from "./crud/blockCategories.crud"; import { createCategoriesCrud } from "./crud/categories.crud"; import { createPageCrud } from "./crud/pages.crud"; import { createPageValidation } from "./crud/pages.validation"; @@ -102,6 +103,13 @@ const setup = (params: CreateCrudParams) => { getLocaleCode }); + const blockCategories = createBlockCategoriesCrud({ + context, + storageOperations, + getTenantId, + getLocaleCode + }); + const pageElements = createPageElementsCrud({ context, storageOperations, @@ -123,7 +131,8 @@ const setup = (params: CreateCrudParams) => { ...menus, ...pages, ...pageElements, - ...categories + ...categories, + ...blockCategories }; 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..d96333f5f59 --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -0,0 +1,303 @@ +/** + * 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,minLength:1,maxLength:100") }), + name: string({ validation: validation.create("required,minLength:1,maxLength:100") }) +})(); + +const UpdateDataModel = withFields({ + name: 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; + + 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 existingBlockCategory = await this.getBlockCategory(input.slug, { + auth: false + }); + if (existingBlockCategory) { + 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: 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); + + 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/graphql.ts b/packages/api-page-builder/src/graphql/graphql.ts index ab433940ab4..fa4b58f1319 100644 --- a/packages/api-page-builder/src/graphql/graphql.ts +++ b/packages/api-page-builder/src/graphql/graphql.ts @@ -5,6 +5,8 @@ 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 { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; export default () => { @@ -15,6 +17,7 @@ export default () => { createPageGraphQL(), createPageElementsGraphQL(), createSettingsGraphQL(), + createBlockCategoryGraphQL(), createInstallGraphQL() ] 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..a59e7e58151 --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts @@ -0,0 +1,86 @@ +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 + } + + input PbBlockCategoryInput { + name: String! + slug: 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/types.ts b/packages/api-page-builder/src/graphql/types.ts index a341a81e88d..14d98723e8d 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -9,6 +9,7 @@ import { Args as PsRenderParams } from "@webiny/api-prerendering-service/render/ import { Args as PsQueueAddParams } from "@webiny/api-prerendering-service/queue/add/types"; import { + BlockCategory, Category, Menu, Page, @@ -501,10 +502,75 @@ export interface SystemCrud { onAfterInstall: Topic; } +export interface PbBlockCategoryInput { + name: string; + slug: 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 PageBuilderContextObject extends PagesCrud, PageElementsCrud, CategoriesCrud, + BlockCategoriesCrud, MenusCrud, SettingsCrud, SystemCrud { diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 1416f0c45c3..9404942f970 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -711,6 +711,7 @@ export interface PageBuilderStorageOperations { menus: MenuStorageOperations; pageElements: PageElementStorageOperations; pages: PageStorageOperations; + blockCategories: BlockCategoryStorageOperations; beforeInit?: (context: PbContext) => Promise; init?: (context: PbContext) => Promise; @@ -719,3 +720,95 @@ export interface PageBuilderStorageOperations { */ upgrade?: UpgradePlugin | null; } + +/** + * @category RecordModel + */ +export interface BlockCategory { + name: string; + slug: 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; +} diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 79a086f3ac6..5c4fce184a2 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -32,7 +32,7 @@ const PageBuilderProviderHOC = (Component: React.FC): React.FC => { const PageBuilderMenu: React.FC = () => { return ( - + }> @@ -57,6 +57,15 @@ const PageBuilderMenu: React.FC = () => { /> + + + + + diff --git a/packages/app-page-builder/src/admin/plugins/permissionRenderer/PageBuilderPermissions/PageBuilderPermissions.tsx b/packages/app-page-builder/src/admin/plugins/permissionRenderer/PageBuilderPermissions/PageBuilderPermissions.tsx index d6bf77de177..d5cad7a0d7e 100644 --- a/packages/app-page-builder/src/admin/plugins/permissionRenderer/PageBuilderPermissions/PageBuilderPermissions.tsx +++ b/packages/app-page-builder/src/admin/plugins/permissionRenderer/PageBuilderPermissions/PageBuilderPermissions.tsx @@ -19,7 +19,7 @@ const PAGE_BUILDER_SETTINGS_ACCESS = `${PAGE_BUILDER}.settings`; const FULL_ACCESS = "full"; const NO_ACCESS = "no"; const CUSTOM_ACCESS = "custom"; -const ENTITIES = ["category", "menu", "page"]; +const ENTITIES = ["category", "menu", "page", "block"]; interface PwOptions { id: string; @@ -228,7 +228,13 @@ export const PageBuilderPermissions: React.FC = ({ - + diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index 8ed9c809d66..6057c3ea8ab 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -10,10 +10,12 @@ import Categories from "../views/Categories/Categories"; import Menus from "../views/Menus/Menus"; import Pages from "../views/Pages/Pages"; import Editor from "../views/Pages/Editor"; +import BlockCategories from "../views/BlockCategories/BlockCategories"; 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[] = [ { @@ -91,6 +93,24 @@ const plugins: RoutePlugin[] = [ }} /> ) + }, + { + name: "route-pb-block-categories", + type: "route", + route: ( + ( + + + + + + + )} + /> + ) } ]; 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..18e059ecc66 --- /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/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..2bb7b7e4055 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -0,0 +1,243 @@ +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 { validation } from "@webiny/validation"; +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/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"]); + + 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/graphql.ts b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts new file mode 100644 index 00000000000..40ac2be3578 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts @@ -0,0 +1,143 @@ +import gql from "graphql-tag"; +import { PbBlockCategory, PbErrorResponse } from "~/types"; + +const BASE_FIELDS = ` + slug + name + createdOn + createdBy { + id + displayName + } +`; + +export const LIST_BLOCK_CATEGORIES = gql` + query ListBlockCategories { + pageBuilder { + listBlockCategories { + data { + ${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 { + ${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 { + ${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 { + ${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/types.ts b/packages/app-page-builder/src/types.ts index 926a09328be..cee2c4e984e 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -812,6 +812,14 @@ export interface PbMenu { slug: string; description: string; } + +export interface PbBlockCategory { + name: string; + slug: string; + createdOn: string; + createdBy: PbIdentity; +} + /** * TODO: have types for both API and app in the same package? * GraphQL response types From 523217a034fc5228c10b107efe45c83e33e4ba40 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Tue, 10 May 2022 14:06:09 +0300 Subject: [PATCH 02/56] ci: add tests for Block Categories API --- .../__tests__/graphql/blockCategories.test.ts | 148 +++++++ .../graphql/blockCategoriesSecurity.test.ts | 410 ++++++++++++++++++ .../graphql/graphql/blockCategories.ts | 75 ++++ .../__tests__/graphql/useGqlHandler.ts | 26 ++ 4 files changed, 659 insertions(+) create mode 100644 packages/api-page-builder/__tests__/graphql/blockCategories.test.ts create mode 100644 packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts create mode 100644 packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts 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..6ecc2972180 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -0,0 +1,148 @@ +import useGqlHandler from "./useGqlHandler"; +import { defaultIdentity } from "../tenancySecurity"; + +jest.setTimeout(100000); + +describe("Block Categories CRUD Test", () => { + const { + createBlockCategory, + deleteBlockCategory, + listBlockCategories, + getBlockCategory, + updateBlockCategory + } = useGqlHandler(); + + test("create, read, update and delete block categories", async () => { + // Test creating, getting and updating three block categories. + for (let i = 0; i < 3; i++) { + const prefix = `block-category-${i}-`; + let data = { + slug: `${prefix}slug`, + name: `${prefix}name` + }; + + 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" + }; + + [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-0-slug", + name: "block-category-0-name-UPDATED", + createdOn: /^20/, + createdBy: defaultIdentity + }, + { + slug: "block-category-1-slug", + name: "block-category-1-name-UPDATED", + createdOn: /^20/, + createdBy: defaultIdentity + }, + { + slug: "block-category-2-slug", + name: "block-category-2-name-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 = `block-category-${i}-`; + const data = { + slug: `${prefix}slug`, + name: `${prefix}name-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 + } + } + } + }); + }); +}); 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..9d28a992b71 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts @@ -0,0 +1,410 @@ +import useGqlHandler from "./useGqlHandler"; +import { identityA, identityB } from "./mocks"; + +function Mock(prefix = "") { + this.slug = `${prefix}slug`; + this.name = `${prefix}name`; +} + +const NOT_AUTHORIZED_RESPONSE = operation => ({ + data: { + pageBuilder: { + [operation]: { + data: null, + error: { + code: "SECURITY_NOT_AUTHORIZED", + data: null, + message: "Not authorized!" + } + } + } + } +}); + +jest.setTimeout(100000); + +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-1-") }); + await createBlockCategory({ data: new Mock("list-block-categories-2-") }); + + const identityBHandler = useGqlHandler({ identity: identityB }); + await identityBHandler.createBlockCategory({ data: new Mock("list-block-categories-3-") }); + await identityBHandler.createBlockCategory({ data: new Mock("list-block-categories-4-") }); + + 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-1-slug", + name: "list-block-categories-1-name" + }, + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-2-slug", + name: "list-block-categories-2-name" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-3-slug", + name: "list-block-categories-3-name" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-4-slug", + name: "list-block-categories-4-name" + } + ], + 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-1-slug", + name: "list-block-categories-1-name" + }, + { + createdBy: identityA, + createdOn: /^20/, + slug: "list-block-categories-2-slug", + name: "list-block-categories-2-name" + } + ], + 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-3-slug", + name: "list-block-categories-3-name" + }, + { + createdBy: identityB, + createdOn: /^20/, + slug: "list-block-categories-4-slug", + name: "list-block-categories-4-name" + } + ], + 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-${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..bc1dd66ee3b --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts @@ -0,0 +1,75 @@ +export const DATA_FIELD = /* GraphQL */ ` + { + slug + name + 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/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index 8c11ddfc31f..0c705726948 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -52,6 +52,15 @@ 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 path from "path"; import fs from "fs"; import { until } from "@webiny/project-utils/testing/helpers/until"; @@ -256,6 +265,23 @@ 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 } }); } }; }; From d8a6b1c7da1b2830721e332830fb08fbd9c1bf4d Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Tue, 10 May 2022 15:17:49 +0300 Subject: [PATCH 03/56] ci: add lifecycle tests for Block Categories --- .../lifecycleEvents.blockCategories.test.ts | 122 ++++++++++++++++++ .../graphql/mocks/lifecycleEvents.ts | 25 ++++ 2 files changed, 147 insertions(+) create mode 100644 packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts 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..602481136bb --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts @@ -0,0 +1,122 @@ +import useGqlHandler from "./useGqlHandler"; + +import { assignBlockCategoryLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; + +const name = "Block Category Lifecycle Events"; +const slug = "block-category-lifecycle-events"; + +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 + } + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + createBlockCategory: { + data: { + name, + slug + }, + 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 + } + }); + + tracker.reset(); + + const [response] = await updateBlockCategory({ + slug: slug, + data: { + slug, + name: `${name} updated` + } + }); + + expect(response).toMatchObject({ + data: { + pageBuilder: { + updateBlockCategory: { + data: { + name: `${name} updated`, + slug + }, + 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 + } + }); + + tracker.reset(); + + const [response] = await deleteBlockCategory({ + slug + }); + expect(response).toMatchObject({ + data: { + pageBuilder: { + deleteBlockCategory: { + data: { + name, + slug + }, + 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/mocks/lifecycleEvents.ts b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts index b7cf55c4c21..b7895428a4a 100644 --- a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts +++ b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts @@ -160,3 +160,28 @@ 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); + }); + }); +}; From 5d5b3261422165584e3b94b84fb6486366daf22b Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Wed, 18 May 2022 18:40:22 +0300 Subject: [PATCH 04/56] fix: Add Block Category slug field validation --- .../admin/views/BlockCategories/BlockCategoriesForm.tsx | 9 ++++++++- .../src/admin/views/BlockCategories/validators.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/validators.ts diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx index 2bb7b7e4055..eb441328504 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -13,6 +13,7 @@ import { SimpleFormHeader } from "@webiny/app-admin/components/SimpleForm"; import { validation } from "@webiny/validation"; +import { blockCategorySlugValidator } from "./validators"; import { GET_BLOCK_CATEGORY, CREATE_BLOCK_CATEGORY, @@ -214,7 +215,13 @@ const CategoriesForm: React.FC = ({ canCreate }) => {
- + 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..b4c59871d6e --- /dev/null +++ b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts @@ -0,0 +1,9 @@ +export const blockCategorySlugValidator = (value: string): boolean => { + if (value.match(/^[a-z]+(\-?[a-z]+)*$/)) { + return true; + } + + throw new Error( + "Block Category slug must consist of only 'a-z' and '-' characters (for example: 'block-category-slug')" + ); +}; From 7a7f98e4703209a0c85050de74898c8aa2658840 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Wed, 18 May 2022 19:14:18 +0300 Subject: [PATCH 05/56] fix: Fix RegExp warning --- .../src/admin/views/BlockCategories/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts index b4c59871d6e..f7f4d0557c7 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts @@ -1,5 +1,5 @@ export const blockCategorySlugValidator = (value: string): boolean => { - if (value.match(/^[a-z]+(\-?[a-z]+)*$/)) { + if (value.match(/^[a-z]+(\-[a-z]+)*$/)) { return true; } From e2746d2df4f7c206ab1826d5bd1932602ac9475a Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Sun, 22 May 2022 14:47:32 +0300 Subject: [PATCH 06/56] feat: Add new API slug validator; Add/update block category tests; --- .../__tests__/graphql/blockCategories.test.ts | 52 +++++++++++--- .../graphql/blockCategoriesSecurity.test.ts | 60 ++++++++++------ .../src/graphql/crud/blockCategories.crud.ts | 2 +- .../admin/views/BlockCategories/validators.ts | 14 ++-- packages/validation/__tests__/slug.test.js | 71 +++++++++++++++++++ packages/validation/src/index.ts | 2 + packages/validation/src/validators/slug.ts | 16 +++++ 7 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 packages/validation/__tests__/slug.test.js create mode 100644 packages/validation/src/validators/slug.ts diff --git a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts index 6ecc2972180..231e1d70529 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -14,8 +14,9 @@ describe("Block Categories CRUD Test", () => { 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 = `block-category-${i}-`; + const prefix = prefixes[i]; let data = { slug: `${prefix}slug`, name: `${prefix}name` @@ -83,20 +84,20 @@ describe("Block Categories CRUD Test", () => { listBlockCategories: { data: [ { - slug: "block-category-0-slug", - name: "block-category-0-name-UPDATED", + slug: "block-category-one-slug", + name: "block-category-one-name-UPDATED", createdOn: /^20/, createdBy: defaultIdentity }, { - slug: "block-category-1-slug", - name: "block-category-1-name-UPDATED", + slug: "block-category-two-slug", + name: "block-category-two-name-UPDATED", createdOn: /^20/, createdBy: defaultIdentity }, { - slug: "block-category-2-slug", - name: "block-category-2-name-UPDATED", + slug: "block-category-three-slug", + name: "block-category-three-name-UPDATED", createdOn: /^20/, createdBy: defaultIdentity } @@ -109,7 +110,7 @@ describe("Block Categories CRUD Test", () => { // After deleting all block categories, list should be empty. for (let i = 0; i < 3; i++) { - const prefix = `block-category-${i}-`; + const prefix = prefixes[i]; const data = { slug: `${prefix}slug`, name: `${prefix}name-UPDATED` @@ -145,4 +146,39 @@ describe("Block Categories CRUD Test", () => { } }); }); + + test("cannot create a block category with invalid slug", async () => { + const [errorResponse] = await createBlockCategory({ + data: { + slug: `invalid--slug--category`, + name: `invalid--slug--category` + } + }); + + const error: 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(errorResponse).toEqual({ + data: { + pageBuilder: { + createBlockCategory: { + data: null, + error + } + } + } + }); + }); }); diff --git a/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts index 9d28a992b71..5c5d03cfa01 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts @@ -23,6 +23,20 @@ const NOT_AUTHORIZED_RESPONSE = operation => ({ 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.*" }], @@ -30,12 +44,16 @@ describe("Block Categories Security Test", () => { }); test(`"listBlockCategories" only returns entries to which the identity has access to`, async () => { - await createBlockCategory({ data: new Mock("list-block-categories-1-") }); - await createBlockCategory({ data: new Mock("list-block-categories-2-") }); + 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-3-") }); - await identityBHandler.createBlockCategory({ data: new Mock("list-block-categories-4-") }); + await identityBHandler.createBlockCategory({ + data: new Mock("list-block-categories-three-") + }); + await identityBHandler.createBlockCategory({ + data: new Mock("list-block-categories-four-") + }); const insufficientPermissions = [ [[], null], @@ -83,26 +101,26 @@ describe("Block Categories Security Test", () => { { createdBy: identityA, createdOn: /^20/, - slug: "list-block-categories-1-slug", - name: "list-block-categories-1-name" + slug: "list-block-categories-one-slug", + name: "list-block-categories-one-name" }, { createdBy: identityA, createdOn: /^20/, - slug: "list-block-categories-2-slug", - name: "list-block-categories-2-name" + slug: "list-block-categories-two-slug", + name: "list-block-categories-two-name" }, { createdBy: identityB, createdOn: /^20/, - slug: "list-block-categories-3-slug", - name: "list-block-categories-3-name" + slug: "list-block-categories-three-slug", + name: "list-block-categories-three-name" }, { createdBy: identityB, createdOn: /^20/, - slug: "list-block-categories-4-slug", - name: "list-block-categories-4-name" + slug: "list-block-categories-four-slug", + name: "list-block-categories-four-name" } ], error: null @@ -126,14 +144,14 @@ describe("Block Categories Security Test", () => { { createdBy: identityA, createdOn: /^20/, - slug: "list-block-categories-1-slug", - name: "list-block-categories-1-name" + slug: "list-block-categories-one-slug", + name: "list-block-categories-one-name" }, { createdBy: identityA, createdOn: /^20/, - slug: "list-block-categories-2-slug", - name: "list-block-categories-2-name" + slug: "list-block-categories-two-slug", + name: "list-block-categories-two-name" } ], error: null @@ -156,14 +174,14 @@ describe("Block Categories Security Test", () => { { createdBy: identityB, createdOn: /^20/, - slug: "list-block-categories-3-slug", - name: "list-block-categories-3-name" + slug: "list-block-categories-three-slug", + name: "list-block-categories-three-name" }, { createdBy: identityB, createdOn: /^20/, - slug: "list-block-categories-4-slug", - name: "list-block-categories-4-name" + slug: "list-block-categories-four-slug", + name: "list-block-categories-four-name" } ], error: null @@ -212,7 +230,7 @@ describe("Block Categories Security Test", () => { identity: identity as any }); - const data = new Mock(`block-category-create-${i}-`); + const data = new Mock(`block-category-create-${intAsString[i]}-`); const [response] = await createBlockCategory({ data }); expect(response).toMatchObject({ data: { diff --git a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts index d96333f5f59..8e082b2c764 100644 --- a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -29,7 +29,7 @@ import WebinyError from "@webiny/error"; import { createTopic } from "@webiny/pubsub"; const CreateDataModel = withFields({ - slug: string({ validation: validation.create("required,minLength:1,maxLength:100") }), + slug: string({ validation: validation.create("required,slug") }), name: string({ validation: validation.create("required,minLength:1,maxLength:100") }) })(); diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts index f7f4d0557c7..465d0be33e3 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts @@ -1,9 +1,13 @@ export const blockCategorySlugValidator = (value: string): boolean => { - if (value.match(/^[a-z]+(\-[a-z]+)*$/)) { - return true; + 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')" + ); } - 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; }; 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')" + ); +}; From 8a9bce75e49de4df66b9444eb258a4161861ba6c Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Thu, 9 Jun 2022 13:09:56 +0300 Subject: [PATCH 07/56] feat: add API for Page Builder Block creation (#2458) * Create API for Page Builder Block creation * tests: add lifecycle tests for Blocks create API * fix: return an GraphQLSchemaPlugin object * fix: fix GraphQLSchemaPlugin creation * fix: change to pageBlock naming in api package * fix: code style issue * fix: change to pageBlock naming in api-ddb package * fix: change to pageBlock naming in api-ddb-es package * fix: fix tests after naming change to pageBlock --- .../src/definitions/pageBlockEntity.ts | 55 +++++++++ .../api-page-builder-so-ddb-es/src/index.ts | 19 ++- .../src/operations/pageBlock/dataLoader.ts | 74 +++++++++++ .../src/operations/pageBlock/fields.ts | 21 ++++ .../src/operations/pageBlock/index.ts | 63 ++++++++++ .../src/operations/pageBlock/keys.ts | 16 +++ .../PageBlockDynamoDbFieldPlugin.ts | 5 + .../api-page-builder-so-ddb-es/src/types.ts | 6 +- .../src/definitions/pageBlockEntity.ts | 55 +++++++++ packages/api-page-builder-so-ddb/src/index.ts | 19 ++- .../src/operations/pageBlock/dataLoader.ts | 74 +++++++++++ .../src/operations/pageBlock/fields.ts | 21 ++++ .../src/operations/pageBlock/index.ts | 63 ++++++++++ .../src/operations/pageBlock/keys.ts | 16 +++ .../PageBlockDynamoDbFieldPlugin.ts | 5 + packages/api-page-builder-so-ddb/src/types.ts | 6 +- .../__tests__/graphql/graphql/pageBlocks.ts | 34 ++++++ .../lifecycleEvents.pageBlocks.test.ts | 73 +++++++++++ .../graphql/mocks/lifecycleEvents.ts | 11 ++ .../__tests__/graphql/pageBlocks.test.ts | 105 ++++++++++++++++ .../__tests__/graphql/useGqlHandler.ts | 7 ++ packages/api-page-builder/src/graphql/crud.ts | 11 +- .../src/graphql/crud/pageBlocks.crud.ts | 115 ++++++++++++++++++ .../api-page-builder/src/graphql/graphql.ts | 4 +- .../src/graphql/graphql/pageBlocks.gql.ts | 44 +++++++ .../api-page-builder/src/graphql/types.ts | 29 +++++ packages/api-page-builder/src/types.ts | 33 +++++ 27 files changed, 976 insertions(+), 8 deletions(-) create mode 100644 packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/pageBlock/fields.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/pageBlock/index.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/operations/pageBlock/keys.ts create mode 100644 packages/api-page-builder-so-ddb-es/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts create mode 100644 packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/pageBlock/fields.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts create mode 100644 packages/api-page-builder-so-ddb/src/operations/pageBlock/keys.ts create mode 100644 packages/api-page-builder-so-ddb/src/plugins/definitions/PageBlockDynamoDbFieldPlugin.ts create mode 100644 packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts create mode 100644 packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts create mode 100644 packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts create mode 100644 packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts create mode 100644 packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts 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 f0e317921bb..25347685322 100644 --- a/packages/api-page-builder-so-ddb-es/src/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/index.ts @@ -38,6 +38,10 @@ 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, @@ -97,7 +101,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Block Category fields required for filtering/sorting. */ - createBlockCategoryDynamoDbFields() + createBlockCategoryDynamoDbFields(), + /** + * Page Block fields required for filtering/sorting. + */ + createPageBlockDynamoDbFields() ]); const entities = { @@ -140,6 +148,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { 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] : {} }) }; @@ -184,6 +197,10 @@ export const createStorageOperations: StorageOperationsFactory = params => { blockCategories: createBlockCategoryStorageOperations({ entity: entities.blockCategories, plugins + }), + pageBlocks: createPageBlockStorageOperations({ + entity: entities.pageBlocks, + plugins }) }; }; 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..abc5955bddc --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/index.ts @@ -0,0 +1,63 @@ +import WebinyError from "@webiny/error"; +import { + PageBlockStorageOperations, + PageBlockStorageOperationsCreateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { PageBlockDataLoader } from "./dataLoader"; +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 +}: CreatePageBlockStorageOperationsParams): PageBlockStorageOperations => { + const dataLoader = new PageBlockDataLoader({ + entity + }); + + 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 + } + ); + } + }; + + return { + create + }; +}; 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/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 94111c02dfe..ef2341cc25b 100644 --- a/packages/api-page-builder-so-ddb-es/src/types.ts +++ b/packages/api-page-builder-so-ddb-es/src/types.ts @@ -21,7 +21,8 @@ export enum ENTITIES { PAGE_ELEMENTS = "PbPageElements", PAGES = "PbPages", PAGES_ES = "PbPagesEs", - BLOCK_CATEGORIES = "PbBlockCategories" + BLOCK_CATEGORIES = "PbBlockCategories", + PAGE_BLOCKS = "PbPageBlocks" } export interface TableModifier { @@ -39,7 +40,8 @@ export interface PageBuilderStorageOperations extends BasePageBuilderStorageOper | "pageElements" | "pages" | "pagesEs" - | "blockCategories", + | "blockCategories" + | "pageBlocks", Entity >; } 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 3fcfa127fff..1b55b972433 100644 --- a/packages/api-page-builder-so-ddb/src/index.ts +++ b/packages/api-page-builder-so-ddb/src/index.ts @@ -30,6 +30,10 @@ 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; @@ -66,7 +70,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { /** * Block Category fields required for filtering/sorting. */ - createBlockCategoryDynamoDbFields() + createBlockCategoryDynamoDbFields(), + /** + * Page Block fields required for filtering/sorting. + */ + createPageBlockDynamoDbFields() ]); const entities = { @@ -104,6 +112,11 @@ export const createStorageOperations: StorageOperationsFactory = params => { 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] : {} }) }; @@ -135,6 +148,10 @@ export const createStorageOperations: StorageOperationsFactory = params => { blockCategories: createBlockCategoryStorageOperations({ entity: entities.blockCategories, plugins + }), + pageBlocks: createPageBlockStorageOperations({ + entity: entities.pageBlocks, + plugins }) }; }; 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..abc5955bddc --- /dev/null +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts @@ -0,0 +1,63 @@ +import WebinyError from "@webiny/error"; +import { + PageBlockStorageOperations, + PageBlockStorageOperationsCreateParams +} from "@webiny/api-page-builder/types"; +import { Entity } from "dynamodb-toolbox"; +import { PageBlockDataLoader } from "./dataLoader"; +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 +}: CreatePageBlockStorageOperationsParams): PageBlockStorageOperations => { + const dataLoader = new PageBlockDataLoader({ + entity + }); + + 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 + } + ); + } + }; + + return { + create + }; +}; 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/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 9f107813dd4..8bcae681606 100644 --- a/packages/api-page-builder-so-ddb/src/types.ts +++ b/packages/api-page-builder-so-ddb/src/types.ts @@ -19,7 +19,8 @@ export enum ENTITIES { MENUS = "PbMenus", PAGE_ELEMENTS = "PbPageElements", PAGES = "PbPages", - BLOCK_CATEGORIES = "PbBlockCategories" + BLOCK_CATEGORIES = "PbBlockCategories", + PAGE_BLOCKS = "PbPageBlocks" } export interface TableModifier { @@ -35,7 +36,8 @@ export interface PageBuilderStorageOperations extends BasePageBuilderStorageOper | "menus" | "pageElements" | "pages" - | "blockCategories", + | "blockCategories" + | "pageBlocks", Entity >; } 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..6024a08820f --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts @@ -0,0 +1,34 @@ +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} + } + } + } +`; 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..afebcc67b15 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts @@ -0,0 +1,73 @@ +import useGqlHandler from "./useGqlHandler"; +import { PageBlock } from "~/types"; + +import { assignPageBlockLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; + +const blockCategory = "block-category-lifecycle-events"; + +const pageBlockData = { + name: "Block Lifecycle Events", + blockCategory, + preview: { src: "https://test.com/src.jpg" }, + content: { some: "block-content" } +}; + +describe("PageBlock Lifecycle Events", () => { + const handler = useGqlHandler({ + plugins: [assignPageBlockLifecycleEvents()] + }); + + const { createPageBlock, 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` + } + }); + // 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); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts index b7895428a4a..54dab8d8a9d 100644 --- a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts +++ b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts @@ -185,3 +185,14 @@ export const assignBlockCategoryLifecycleEvents = () => { }); }); }; + +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); + }); + }); +}; 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..08731a241d7 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -0,0 +1,105 @@ +import useGqlHandler from "./useGqlHandler"; +import { defaultIdentity } from "../tenancySecurity"; +import { ErrorOptions } from "@webiny/error"; + +jest.setTimeout(100000); + +describe("Page Blocks Test", () => { + const { createPageBlock, createBlockCategory } = useGqlHandler(); + + test("create page blocks", async () => { + // Create block category + await createBlockCategory({ + data: { + slug: "block-category", + name: "block-category-name" + } + }); + + for (let i = 0; i < 3; i++) { + const prefix = `page-block-${i}-`; + const data = { + name: `${prefix}name`, + blockCategory: "block-category", + preview: { src: `https://test.com/${prefix}/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 + } + } + } + }); + } + }); + + test("cannot create page block if no such 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 + } + } + } + }); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index 0c705726948..344dea19682 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -61,6 +61,8 @@ import { GET_BLOCK_CATEGORY } from "./graphql/blockCategories"; +import { CREATE_PAGE_BLOCK } from "./graphql/pageBlocks"; + import path from "path"; import fs from "fs"; import { until } from "@webiny/project-utils/testing/helpers/until"; @@ -282,6 +284,11 @@ export default ({ permissions, identity, plugins, storageOperationPlugins }: Par }, 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 } }); } }; }; diff --git a/packages/api-page-builder/src/graphql/crud.ts b/packages/api-page-builder/src/graphql/crud.ts index cf6a9e3d4d0..52cbd513f34 100644 --- a/packages/api-page-builder/src/graphql/crud.ts +++ b/packages/api-page-builder/src/graphql/crud.ts @@ -1,5 +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"; @@ -110,6 +111,13 @@ const setup = (params: CreateCrudParams) => { getLocaleCode }); + const pageBlocks = createPageBlocksCrud({ + context, + storageOperations, + getTenantId, + getLocaleCode + }); + const pageElements = createPageElementsCrud({ context, storageOperations, @@ -132,7 +140,8 @@ const setup = (params: CreateCrudParams) => { ...pages, ...pageElements, ...categories, - ...blockCategories + ...blockCategories, + ...pageBlocks }; if (!storageOperations.init) { 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..56fc74ac674 --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -0,0 +1,115 @@ +/** + * 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, + OnBeforePageBlockCreateTopicParams, + PageBuilderContextObject, + PageBuilderStorageOperations, + PageBlock, + PageBlocksCrud, + PbContext +} 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"; + +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 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(); + + return { + /** + * Lifecycle events + */ + onBeforePageBlockCreate, + onAfterPageBlockCreate, + + 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 + } + ); + } + } + }; +}; diff --git a/packages/api-page-builder/src/graphql/graphql.ts b/packages/api-page-builder/src/graphql/graphql.ts index fa4b58f1319..fa11dcab7ef 100644 --- a/packages/api-page-builder/src/graphql/graphql.ts +++ b/packages/api-page-builder/src/graphql/graphql.ts @@ -6,6 +6,7 @@ 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"; @@ -18,6 +19,7 @@ export default () => { createPageElementsGraphQL(), createSettingsGraphQL(), createBlockCategoryGraphQL(), - createInstallGraphQL() + createInstallGraphQL(), + createPageBlockGraphQL ] as GraphQLSchemaPlugin[]; }; 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..bbd84a2fac8 --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -0,0 +1,44 @@ +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! + } + + # Response types + type PbPageBlockResponse { + data: PbPageBlock + error: PbError + } + + extend type PbMutation { + createPageBlock(data: PbCreatePageBlockInput!): PbPageBlockResponse + } + `, + resolvers: { + PbMutation: { + createPageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.createPageBlock(args.data); + }); + } + } + } +}); diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index 14d98723e8d..527b347332b 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -9,6 +9,7 @@ import { Args as PsRenderParams } from "@webiny/api-prerendering-service/render/ import { Args as PsQueueAddParams } from "@webiny/api-prerendering-service/queue/add/types"; import { + PageBlock, BlockCategory, Category, Menu, @@ -566,11 +567,39 @@ export interface BlockCategoriesCrud { onAfterBlockCategoryDelete: Topic; } +/** + * @category Lifecycle events + */ +export interface OnBeforePageBlockCreateTopicParams { + pageBlock: PageBlock; +} + +/** + * @category Lifecycle events + */ +export interface OnAfterPageBlockCreateTopicParams { + pageBlock: PageBlock; +} + +/** + * @category PageBlocks + */ +export interface PageBlocksCrud { + createPageBlock(data: Record): Promise; + + /** + * Lifecycle events + */ + onBeforePageBlockCreate: Topic; + onAfterPageBlockCreate: Topic; +} + export interface PageBuilderContextObject extends PagesCrud, PageElementsCrud, CategoriesCrud, BlockCategoriesCrud, + PageBlocksCrud, MenusCrud, SettingsCrud, SystemCrud { diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 9404942f970..4b49a833ff3 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -712,6 +712,7 @@ export interface PageBuilderStorageOperations { pageElements: PageElementStorageOperations; pages: PageStorageOperations; blockCategories: BlockCategoryStorageOperations; + pageBlocks: PageBlockStorageOperations; beforeInit?: (context: PbContext) => Promise; init?: (context: PbContext) => Promise; @@ -812,3 +813,35 @@ export interface BlockCategoryStorageOperations { update(params: BlockCategoryStorageOperationsUpdateParams): Promise; delete(params: BlockCategoryStorageOperationsDeleteParams): Promise; } + +/** + * @category RecordModel + */ +export interface PageBlock { + id: string; + name: string; + blockCategory: string; + content: File; + preview: File; + createdOn: string; + createdBy: CreatedBy; + tenant: string; + locale: string; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperationsCreateParams { + input: Record; + pageBlock: PageBlock; +} + +/** + * @category StorageOperations + * @category PageBlockStorageOperations + */ +export interface PageBlockStorageOperations { + create(params: PageBlockStorageOperationsCreateParams): Promise; +} From e8d98db4b258d656a11a5b1392a0cd372ec48af3 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Thu, 16 Jun 2022 18:13:45 +0300 Subject: [PATCH 08/56] fix: fix validation for empty string slug value (#2486) --- .../__tests__/graphql/blockCategories.test.ts | 85 ++++++++++++++++++- .../src/graphql/crud/blockCategories.crud.ts | 13 ++- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts index 231e1d70529..d0bd4f7834a 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -147,15 +147,47 @@ describe("Block Categories CRUD Test", () => { }); }); - test("cannot create a block category with invalid slug", async () => { - const [errorResponse] = await createBlockCategory({ + test("cannot create a block category with empty or invalid slug", async () => { + const [emptySlugErrorResponse] = await createBlockCategory({ + data: { + slug: ``, + name: `empty-slug-category` + } + }); + + 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` } }); - const error: ErrorOptions = { + const invalidSlugError: ErrorOptions = { code: "VALIDATION_FAILED_INVALID_FIELDS", message: "Validation failed.", data: { @@ -170,10 +202,55 @@ describe("Block Categories CRUD Test", () => { } }; - expect(errorResponse).toEqual({ + 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` } + }); + + 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 } diff --git a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts index 8e082b2c764..385e755211d 100644 --- a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -75,6 +75,13 @@ export const createBlockCategoriesCrud = ( 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, @@ -166,6 +173,9 @@ export const createBlockCategoriesCrud = ( 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 }); @@ -173,9 +183,6 @@ export const createBlockCategoriesCrud = ( 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: BlockCategory = await createDataModel.toJSON(); From 8ead3ee51f0d254e9f8534994f21f59fa8f59e38 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Thu, 16 Jun 2022 18:14:16 +0300 Subject: [PATCH 09/56] feat: page builder blocks v2 read operations (#2487) * feat: add Page Blocks API for read operations * fix: fix code style issues --- .../src/operations/pageBlock/index.ts | 83 +++++++++++++- .../src/operations/pageBlock/index.ts | 83 +++++++++++++- .../__tests__/graphql/graphql/pageBlocks.ts | 22 ++++ .../__tests__/graphql/pageBlocks.test.ts | 101 ++++++++++++++++-- .../__tests__/graphql/useGqlHandler.ts | 8 +- .../src/graphql/crud/pageBlocks.crud.ts | 82 +++++++++++++- .../src/graphql/graphql/pageBlocks.gql.ts | 26 +++++ .../api-page-builder/src/graphql/types.ts | 8 ++ packages/api-page-builder/src/types.ts | 43 ++++++++ 9 files changed, 444 insertions(+), 12 deletions(-) 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 index abc5955bddc..4a659768a10 100644 --- 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 @@ -1,10 +1,18 @@ import WebinyError from "@webiny/error"; import { + PageBlock, PageBlockStorageOperations, - PageBlockStorageOperationsCreateParams + PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsGetParams, + PageBlockStorageOperationsListParams } 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"; @@ -17,12 +25,81 @@ export interface CreatePageBlockStorageOperationsParams { plugins: PluginsContainer; } export const createPageBlockStorageOperations = ({ - entity + 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; @@ -58,6 +135,8 @@ export const createPageBlockStorageOperations = ({ }; return { + get, + list, create }; }; 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 index abc5955bddc..4a659768a10 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts @@ -1,10 +1,18 @@ import WebinyError from "@webiny/error"; import { + PageBlock, PageBlockStorageOperations, - PageBlockStorageOperationsCreateParams + PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsGetParams, + PageBlockStorageOperationsListParams } 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"; @@ -17,12 +25,81 @@ export interface CreatePageBlockStorageOperationsParams { plugins: PluginsContainer; } export const createPageBlockStorageOperations = ({ - entity + 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; @@ -58,6 +135,8 @@ export const createPageBlockStorageOperations = ({ }; return { + get, + list, create }; }; diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts index 6024a08820f..ce6856657a2 100644 --- a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts +++ b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts @@ -32,3 +32,25 @@ export const CREATE_PAGE_BLOCK = /* GraphQL */ ` } } `; + +export const LIST_PAGE_BLOCKS = /* GraphQL */ ` + query ListPageBlocks { + pageBuilder { + listPageBlocks { + 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} + } + } + } +`; diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts index 08731a241d7..5c2a9f70364 100644 --- a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -5,9 +5,12 @@ import { ErrorOptions } from "@webiny/error"; jest.setTimeout(100000); describe("Page Blocks Test", () => { - const { createPageBlock, createBlockCategory } = useGqlHandler(); + const { createPageBlock, getPageBlock, listPageBlocks, createBlockCategory } = useGqlHandler(); + + test("create, read page blocks", async () => { + const ids = []; + const prefixes = ["page-block-one-", "page-block-two-", "page-block-three-"]; - test("create page blocks", async () => { // Create block category await createBlockCategory({ data: { @@ -16,12 +19,13 @@ describe("Page Blocks Test", () => { } }); + // Test creating, getting three page blocks. for (let i = 0; i < 3; i++) { - const prefix = `page-block-${i}-`; + const prefix = prefixes[i]; const data = { name: `${prefix}name`, - blockCategory: "block-category", - preview: { src: `https://test.com/${prefix}/src.jpg` }, + blockCategory: `block-category`, + preview: { src: `https://test.com/${prefix}name/src.jpg` }, content: { some: `${prefix}content` } }; @@ -40,10 +44,77 @@ describe("Page Blocks Test", () => { } } }); + + ids.push(createPageBlockResponse.data.pageBuilder.createPageBlock.data.id); + + const [getPageBlockResponse] = await getPageBlock({ id: ids[i] }); + expect(getPageBlockResponse).toMatchObject({ + data: { + pageBuilder: { + getPageBlock: { + data, + 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" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[0], + name: "page-block-one-name", + preview: { + src: "https://test.com/page-block-one-name/src.jpg" + } + }, + { + blockCategory: "block-category", + content: { + some: "page-block-two-content" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[1], + name: "page-block-two-name", + preview: { + src: "https://test.com/page-block-two-name/src.jpg" + } + }, + { + blockCategory: "block-category", + content: { + some: "page-block-three-content" + }, + createdBy: defaultIdentity, + createdOn: /^20/, + id: ids[2], + name: "page-block-three-name", + preview: { + src: "https://test.com/page-block-three-name/src.jpg" + } + } + ], + error: null + } + } + } + }); }); - test("cannot create page block if no such block category", async () => { + test("cannot create page block with empty or missing block category", async () => { const [createPageBlockEmptyCategoryResponse] = await createPageBlock({ data: { name: "name", @@ -102,4 +173,22 @@ describe("Page Blocks Test", () => { } }); }); + + 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." + } + } + } + } + }); + }); }); diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index 344dea19682..237653f5f62 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -61,7 +61,7 @@ import { GET_BLOCK_CATEGORY } from "./graphql/blockCategories"; -import { CREATE_PAGE_BLOCK } from "./graphql/pageBlocks"; +import { CREATE_PAGE_BLOCK, LIST_PAGE_BLOCKS, GET_PAGE_BLOCK } from "./graphql/pageBlocks"; import path from "path"; import fs from "fs"; @@ -289,6 +289,12 @@ export default ({ permissions, identity, plugins, storageOperationPlugins }: Par // Page Blocks. async createPageBlock(variables: Record) { return invoke({ body: { query: CREATE_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/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index 56fc74ac674..e77e9766300 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -21,10 +21,11 @@ import { PageBuilderStorageOperations, PageBlock, PageBlocksCrud, + PageBlockStorageOperationsListParams, PbContext } from "~/types"; import checkBasePermissions from "./utils/checkBasePermissions"; -//import checkOwnPermissions from "./utils/checkOwnPermissions"; +import checkOwnPermissions from "./utils/checkOwnPermissions"; import { NotFoundError } from "@webiny/handler-graphql"; import WebinyError from "@webiny/error"; import { createTopic } from "@webiny/pubsub"; @@ -57,6 +58,85 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl onBeforePageBlockCreate, onAfterPageBlockCreate, + 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(params) { + const permission = await checkBasePermissions(context, PERMISSION_NAME, { + rwd: "r" + }); + + const { sort, where } = params || {}; + + 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" }); diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts index bbd84a2fac8..6edab084146 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -22,17 +22,43 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ 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 } `, resolvers: { + PbQuery: { + getPageBlock: async (_, args: any, context) => { + return resolve(() => { + return context.pageBuilder.getPageBlock(args.id); + }); + }, + listPageBlocks: async (_, __, context) => { + return resolve(() => { + return context.pageBuilder.listPageBlocks(); + }); + } + }, PbMutation: { createPageBlock: async (_, args: any, context) => { return resolve(() => { diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index 527b347332b..88eaa77d636 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -567,6 +567,12 @@ export interface BlockCategoriesCrud { onAfterBlockCategoryDelete: Topic; } +export interface ListPageBlocksParams { + sort?: string[]; + where?: { + blockCategory?: string; + }; +} /** * @category Lifecycle events */ @@ -585,6 +591,8 @@ export interface OnAfterPageBlockCreateTopicParams { * @category PageBlocks */ export interface PageBlocksCrud { + getPageBlock(id: string): Promise; + listPageBlocks(params?: ListPageBlocksParams): Promise; createPageBlock(data: Record): Promise; /** diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 4b49a833ff3..444b3a0ee04 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -829,6 +829,39 @@ export interface PageBlock { 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 @@ -843,5 +876,15 @@ export interface PageBlockStorageOperationsCreateParams { * @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; } From 14155ba79cbf19f0facd58bd3b263d209598f18e Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Tue, 21 Jun 2022 14:07:00 +0300 Subject: [PATCH 10/56] feat: update and delete operations for Page Blocks (#2494) * feat: update and delete operations for Page Blocks * fix: fix code style issues --- .../src/operations/pageBlock/index.ts | 76 ++++++++- .../src/operations/pageBlock/index.ts | 76 ++++++++- .../__tests__/graphql/graphql/pageBlocks.ts | 22 +++ .../lifecycleEvents.pageBlocks.test.ts | 60 ++++++- .../graphql/mocks/lifecycleEvents.ts | 14 ++ .../__tests__/graphql/pageBlocks.test.ts | 158 ++++++++++++++++-- .../__tests__/graphql/useGqlHandler.ts | 14 +- .../src/graphql/crud/pageBlocks.crud.ts | 114 +++++++++++++ .../src/graphql/graphql/pageBlocks.gql.ts | 19 +++ .../api-page-builder/src/graphql/types.ts | 36 ++++ packages/api-page-builder/src/types.ts | 21 +++ 11 files changed, 589 insertions(+), 21 deletions(-) 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 index 4a659768a10..de3a7472d37 100644 --- 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 @@ -3,8 +3,10 @@ import { PageBlock, PageBlockStorageOperations, PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsDeleteParams, PageBlockStorageOperationsGetParams, - PageBlockStorageOperationsListParams + PageBlockStorageOperationsListParams, + PageBlockStorageOperationsUpdateParams } from "@webiny/api-page-builder/types"; import { Entity } from "dynamodb-toolbox"; import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; @@ -134,9 +136,79 @@ export const createPageBlockStorageOperations = ({ } }; + 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 + create, + update, + delete: deletePageBlock }; }; 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 index 4a659768a10..de3a7472d37 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/index.ts @@ -3,8 +3,10 @@ import { PageBlock, PageBlockStorageOperations, PageBlockStorageOperationsCreateParams, + PageBlockStorageOperationsDeleteParams, PageBlockStorageOperationsGetParams, - PageBlockStorageOperationsListParams + PageBlockStorageOperationsListParams, + PageBlockStorageOperationsUpdateParams } from "@webiny/api-page-builder/types"; import { Entity } from "dynamodb-toolbox"; import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; @@ -134,9 +136,79 @@ export const createPageBlockStorageOperations = ({ } }; + 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 + create, + update, + delete: deletePageBlock }; }; diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts index ce6856657a2..50edff4703a 100644 --- a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts +++ b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts @@ -33,6 +33,17 @@ export const CREATE_PAGE_BLOCK = /* GraphQL */ ` } `; +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 { pageBuilder { @@ -54,3 +65,14 @@ export const GET_PAGE_BLOCK = /* GraphQL */ ` } } `; + +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.pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts index afebcc67b15..055b53b8ce2 100644 --- a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts @@ -6,18 +6,18 @@ import { assignPageBlockLifecycleEvents, tracker } from "./mocks/lifecycleEvents const blockCategory = "block-category-lifecycle-events"; const pageBlockData = { - name: "Block Lifecycle Events", + name: "Page Block Lifecycle Events", blockCategory, preview: { src: "https://test.com/src.jpg" }, - content: { some: "block-content" } + content: { some: "page-block-content" } }; -describe("PageBlock Lifecycle Events", () => { +describe("Page Block Lifecycle Events", () => { const handler = useGqlHandler({ plugins: [assignPageBlockLifecycleEvents()] }); - const { createPageBlock, createBlockCategory } = handler; + const { createPageBlock, updatePageBlock, deletePageBlock, createBlockCategory } = handler; const createDummyPageBlock = async (): Promise => { const [response] = await createPageBlock({ @@ -70,4 +70,56 @@ describe("PageBlock Lifecycle Events", () => { 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 54dab8d8a9d..78b7f8fdf31 100644 --- a/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts +++ b/packages/api-page-builder/__tests__/graphql/mocks/lifecycleEvents.ts @@ -194,5 +194,19 @@ export const assignPageBlockLifecycleEvents = () => { 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 index 5c2a9f70364..5f2c5d0c708 100644 --- a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -5,9 +5,16 @@ import { ErrorOptions } from "@webiny/error"; jest.setTimeout(100000); describe("Page Blocks Test", () => { - const { createPageBlock, getPageBlock, listPageBlocks, createBlockCategory } = useGqlHandler(); + const { + createPageBlock, + getPageBlock, + updatePageBlock, + listPageBlocks, + deletePageBlock, + createBlockCategory + } = useGqlHandler(); - test("create, read page blocks", async () => { + test("create, read, update and delete page blocks", async () => { const ids = []; const prefixes = ["page-block-one-", "page-block-two-", "page-block-three-"]; @@ -19,7 +26,7 @@ describe("Page Blocks Test", () => { } }); - // Test creating, getting three page blocks. + // Test creating, getting and updating three page blocks. for (let i = 0; i < 3; i++) { const prefix = prefixes[i]; const data = { @@ -58,6 +65,32 @@ describe("Page Blocks Test", () => { } } }); + + 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. @@ -70,40 +103,40 @@ describe("Page Blocks Test", () => { { blockCategory: "block-category", content: { - some: "page-block-one-content" + some: "page-block-one-content-UPDATED" }, createdBy: defaultIdentity, createdOn: /^20/, id: ids[0], - name: "page-block-one-name", + name: "page-block-one-name-UPDATED", preview: { - src: "https://test.com/page-block-one-name/src.jpg" + src: "https://test.com/page-block-one-name-UPDATED/src.jpg" } }, { blockCategory: "block-category", content: { - some: "page-block-two-content" + some: "page-block-two-content-UPDATED" }, createdBy: defaultIdentity, createdOn: /^20/, id: ids[1], - name: "page-block-two-name", + name: "page-block-two-name-UPDATED", preview: { - src: "https://test.com/page-block-two-name/src.jpg" + src: "https://test.com/page-block-two-name-UPDATED/src.jpg" } }, { blockCategory: "block-category", content: { - some: "page-block-three-content" + some: "page-block-three-content-UPDATED" }, createdBy: defaultIdentity, createdOn: /^20/, id: ids[2], - name: "page-block-three-name", + name: "page-block-three-name-UPDATED", preview: { - src: "https://test.com/page-block-three-name/src.jpg" + src: "https://test.com/page-block-three-name-UPDATED/src.jpg" } } ], @@ -112,6 +145,36 @@ describe("Page Blocks Test", () => { } } }); + + // 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 () => { @@ -174,6 +237,77 @@ describe("Page Blocks Test", () => { }); }); + test("cannot update page block with empty or missing block category", async () => { + await createBlockCategory({ + data: { + slug: "block-category", + name: "block-category-name" + } + }); + + 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({ diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index 237653f5f62..c426342bc60 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -61,7 +61,13 @@ import { GET_BLOCK_CATEGORY } from "./graphql/blockCategories"; -import { CREATE_PAGE_BLOCK, LIST_PAGE_BLOCKS, GET_PAGE_BLOCK } from "./graphql/pageBlocks"; +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"; @@ -290,6 +296,12 @@ export default ({ permissions, identity, plugins, storageOperationPlugins }: Par 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 } }); }, diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index e77e9766300..21594b46ae0 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -16,7 +16,11 @@ import { object } from "commodo-fields-object"; import { validation } from "@webiny/validation"; import { OnAfterPageBlockCreateTopicParams, + OnAfterPageBlockDeleteTopicParams, + OnAfterPageBlockUpdateTopicParams, OnBeforePageBlockCreateTopicParams, + OnBeforePageBlockDeleteTopicParams, + OnBeforePageBlockUpdateTopicParams, PageBuilderContextObject, PageBuilderStorageOperations, PageBlock, @@ -37,6 +41,13 @@ const CreateDataModel = withFields({ 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 { @@ -50,6 +61,10 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl const onBeforePageBlockCreate = createTopic(); const onAfterPageBlockCreate = createTopic(); + const onBeforePageBlockUpdate = createTopic(); + const onAfterPageBlockUpdate = createTopic(); + const onBeforePageBlockDelete = createTopic(); + const onAfterPageBlockDelete = createTopic(); return { /** @@ -57,6 +72,10 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl */ onBeforePageBlockCreate, onAfterPageBlockCreate, + onBeforePageBlockUpdate, + onAfterPageBlockUpdate, + onBeforePageBlockDelete, + onAfterPageBlockDelete, async getPageBlock(id) { const permission = await checkBasePermissions(context, PERMISSION_NAME, { @@ -190,6 +209,101 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl } ); } + }, + + 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 + } + ); + } } }; }; diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts index 6edab084146..6611067713d 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -22,6 +22,13 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ preview: JSON! } + input PbUpdatePageBlockInput { + name: String + blockCategory: String + content: JSON + preview: JSON + } + input PbListPageBlocksWhereInput { blockCategory: String } @@ -44,6 +51,8 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ extend type PbMutation { createPageBlock(data: PbCreatePageBlockInput!): PbPageBlockResponse + updatePageBlock(id: ID!, data: PbUpdatePageBlockInput!): PbPageBlockResponse + deletePageBlock(id: ID!): PbPageBlockResponse } `, resolvers: { @@ -64,6 +73,16 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ 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/types.ts b/packages/api-page-builder/src/graphql/types.ts index 88eaa77d636..5d9399c9dcf 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -587,6 +587,36 @@ 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 */ @@ -594,12 +624,18 @@ 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; /** * Lifecycle events */ onBeforePageBlockCreate: Topic; onAfterPageBlockCreate: Topic; + onBeforePageBlockUpdate: Topic; + onAfterPageBlockUpdate: Topic; + onBeforePageBlockDelete: Topic; + onAfterPageBlockDelete: Topic; } export interface PageBuilderContextObject diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 444b3a0ee04..07232931e28 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -871,6 +871,24 @@ export interface PageBlockStorageOperationsCreateParams { 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 @@ -886,5 +904,8 @@ export interface PageBlockStorageOperations { list( params: PageBlockStorageOperationsListParams ): Promise; + create(params: PageBlockStorageOperationsCreateParams): Promise; + update(params: PageBlockStorageOperationsUpdateParams): Promise; + delete(params: PageBlockStorageOperationsDeleteParams): Promise; } From 35f5b72b2dfb588b4c2e4baea7f9d0cd183501be Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Tue, 21 Jun 2022 14:08:24 +0300 Subject: [PATCH 11/56] fix: check for existing Page Blocks before Block Category delete operation (#2495) --- .../__tests__/graphql/blockCategories.test.ts | 139 +++++++++++++++++- .../src/graphql/crud/blockCategories.crud.ts | 15 ++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts index d0bd4f7834a..50426bf5565 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -9,7 +9,11 @@ describe("Block Categories CRUD Test", () => { deleteBlockCategory, listBlockCategories, getBlockCategory, - updateBlockCategory + updateBlockCategory, + createPageBlock, + listPageBlocks, + deletePageBlock, + until } = useGqlHandler(); test("create, read, update and delete block categories", async () => { @@ -258,4 +262,137 @@ describe("Block Categories CRUD Test", () => { } }); }); + + test("cannot delete block category if in use by at least one page block", async () => { + await createBlockCategory({ + data: { + slug: `delete-block-cat`, + name: `name` + } + }); + + 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` + }, + error: null + }) + ); + }); }); diff --git a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts index 385e755211d..a29670db4cf 100644 --- a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -284,6 +284,21 @@ export const createBlockCategoriesCrud = ( 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 From cf75eb3a17301ac866f2be9cd6e79d52c8f64e43 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 15 Jul 2022 10:45:04 +0300 Subject: [PATCH 12/56] fix: fix not working where filter (#2511) * fix: fix not working where filter * fix: fix failing test --- .../__tests__/graphql/graphql/pageBlocks.ts | 4 +- .../__tests__/graphql/pageBlocks.test.ts | 121 ++++++++++++++++++ .../src/graphql/crud/pageBlocks.crud.ts | 10 +- .../src/graphql/graphql/pageBlocks.gql.ts | 4 +- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts index 50edff4703a..846cf3b2cdd 100644 --- a/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts +++ b/packages/api-page-builder/__tests__/graphql/graphql/pageBlocks.ts @@ -45,9 +45,9 @@ export const UPDATE_PAGE_BLOCK = /* GraphQL */ ` `; export const LIST_PAGE_BLOCKS = /* GraphQL */ ` - query ListPageBlocks { + query ListPageBlocks($where: PbListPageBlocksWhereInput) { pageBuilder { - listPageBlocks { + listPageBlocks(where: $where) { data ${DATA_FIELD} error ${ERROR_FIELD} } diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts index 5f2c5d0c708..7ef2f9e26d2 100644 --- a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -325,4 +325,125 @@ describe("Page Blocks Test", () => { } }); }); + + 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" + } + }); + + await createBlockCategory({ + data: { + slug: "block-category-two", + name: "block-category-two-name" + } + }); + + 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/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index 21594b46ae0..cef75356dc1 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -120,13 +120,21 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl return pageBlock; }, - async listPageBlocks(params) { + 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, diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts index 6611067713d..854e177d0a8 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -62,9 +62,9 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ return context.pageBuilder.getPageBlock(args.id); }); }, - listPageBlocks: async (_, __, context) => { + listPageBlocks: async (_, args: any, context) => { return resolve(() => { - return context.pageBuilder.listPageBlocks(); + return context.pageBuilder.listPageBlocks(args); }); } }, From 01aefbfcc1f5acbe9fcfcff5d0c98bdda85536e7 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 15 Jul 2022 11:49:54 +0300 Subject: [PATCH 13/56] Page builder blocks v2 UI list page blocks (#2496) * feat: add Page Blocks page to Page Builder UI app * fix: fix code style issues * feat: add empty state to Page Blocks list * fix: fix code style comments * fix: change label value * feat: delete/edit Page Block functinality * fix: fix i18n namespace names for Page Block feature pages --- packages/app-page-builder/src/PageBuilder.tsx | 5 + .../src/admin/plugins/routes.tsx | 19 ++ .../BlockCategoriesDataList.tsx | 2 +- .../BlockCategories/BlockCategoriesForm.tsx | 2 +- .../admin/views/BlockCategories/graphql.ts | 10 +- .../PageBlocks/BlocksByCategoriesDataList.tsx | 168 +++++++++++++++ .../src/admin/views/PageBlocks/PageBlocks.tsx | 63 ++++++ .../views/PageBlocks/PageBlocksDataList.tsx | 195 ++++++++++++++++++ .../admin/views/PageBlocks/PageBlocksForm.tsx | 156 ++++++++++++++ .../src/admin/views/PageBlocks/graphql.ts | 155 ++++++++++++++ packages/app-page-builder/src/types.ts | 10 + 11 files changed, 778 insertions(+), 7 deletions(-) create mode 100644 packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx create mode 100644 packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx create mode 100644 packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx create mode 100644 packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx create mode 100644 packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 5c4fce184a2..92222dc4566 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -64,6 +64,11 @@ const PageBuilderMenu: React.FC = () => { label={"Categories"} path="/page-builder/block-categories" /> +
diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index 6057c3ea8ab..8f9e1cd2148 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -11,6 +11,7 @@ import Menus from "../views/Menus/Menus"; import Pages from "../views/Pages/Pages"; import Editor from "../views/Pages/Editor"; import BlockCategories from "../views/BlockCategories/BlockCategories"; +import PageBlocks from "../views/PageBlocks/PageBlocks"; const ROLE_PB_CATEGORY = "pb.category"; const ROLE_PB_MENUS = "pb.menu"; @@ -111,6 +112,24 @@ const plugins: RoutePlugin[] = [ )} /> ) + }, + { + name: "route-pb-page-blocks", + type: "route", + route: ( + ( + + + + + + + )} + /> + ) } ]; diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx index 18e059ecc66..4a1e696d877 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx @@ -28,7 +28,7 @@ import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18 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/categories/data-list"); +const t = i18n.ns("app-page-builder/admin/block-categories/data-list"); interface CreatableItem { createdBy?: { diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx index eb441328504..27facff39e1 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -38,7 +38,7 @@ 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/categories/form"); +const t = i18n.ns("app-page-builder/admin/block-categories/form"); const ButtonWrapper = styled("div")({ display: "flex", diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts index 40ac2be3578..a582fdfd7b2 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts @@ -1,7 +1,7 @@ import gql from "graphql-tag"; import { PbBlockCategory, PbErrorResponse } from "~/types"; -const BASE_FIELDS = ` +export const PAGE_BLOCK_CATEGORY_BASE_FIELDS = ` slug name createdOn @@ -16,7 +16,7 @@ export const LIST_BLOCK_CATEGORIES = gql` pageBuilder { listBlockCategories { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { data @@ -47,7 +47,7 @@ export const GET_BLOCK_CATEGORY = gql` pageBuilder { getBlockCategory(slug: $slug){ data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code @@ -81,7 +81,7 @@ export const CREATE_BLOCK_CATEGORY = gql` pageBuilder { blockCategory: createBlockCategory(data: $data) { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code @@ -117,7 +117,7 @@ export const UPDATE_BLOCK_CATEGORY = gql` pageBuilder { blockCategory: updateBlockCategory(slug: $slug, data: $data) { data { - ${BASE_FIELDS} + ${PAGE_BLOCK_CATEGORY_BASE_FIELDS} } error { code 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..2be05c90455 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { i18n } from "@webiny/app/i18n"; +import { useRouter } from "@webiny/react-router"; +import { useQuery } from "@apollo/react-hooks"; +import orderBy from "lodash/orderBy"; + +import { + DataList, + DataListModalOverlay, + DataListModalOverlayAction, + ScrollList, + ListItem, + ListItemText, + ListItemTextSecondary +} from "@webiny/ui/List"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Select } from "@webiny/ui/Select"; +import SearchUI from "@webiny/app-admin/components/SearchUI"; +import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/filter-24px.svg"; + +import { PbBlockCategory, PbPageBlock } from "~/types"; +import { LIST_PAGE_BLOCKS_AND_CATEGORIES } from "./graphql"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/by-categories-data-list"); + +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" + } +]; + +const BlocksByCategoriesDataList = () => { + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState(SORTERS[0].sort); + const { history } = useRouter(); + 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); + + return ( + + } + 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`} + + + ); + })} + + )} + + ); +}; + +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..3d7077d95a5 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocks.tsx @@ -0,0 +1,63 @@ +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 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..5bf49f4b479 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx @@ -0,0 +1,195 @@ +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 PageBlocksForm from "./PageBlocksForm"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/data-list"); + +const List = styled("div")({ + display: "grid", + rowGap: "8px", + 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", + 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%)", + height: "120px", + padding: "24px" +}); + +const ListItemText = styled("span")({ + textTransform: "uppercase" +}); + +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} + + {canEdit(pageBlock) && ( + + history.push( + `/page-builder/page-blocks?category=${selectedBlocksCategory}&id=${pageBlock.id}` + ) + } + /> + )} + {canDelete(pageBlock) && ( + deleteItem(pageBlock)} /> + )} + + + ))} + + + + ); +}; + +export default PageBlocksDataList; diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx new file mode 100644 index 00000000000..7fa9714928b --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksForm.tsx @@ -0,0 +1,156 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { useMutation, useQuery } from "@apollo/react-hooks"; +import pick from "lodash/pick"; + +import { i18n } from "@webiny/app/i18n"; +import { Form } from "@webiny/form"; +import { Grid, Cell } from "@webiny/ui/Grid"; +import { ButtonPrimary } from "@webiny/ui/Button"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { SimpleFormContent } from "@webiny/app-admin/components/SimpleForm"; +import { validation } from "@webiny/validation"; +import { useRouter } from "@webiny/react-router"; +import { Input } from "@webiny/ui/Input"; +import { Select } from "@webiny/ui/Select"; +import { Checkbox } from "@webiny/ui/Checkbox"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { Dialog, DialogCancel, DialogTitle, DialogActions, DialogContent } from "@webiny/ui/Dialog"; + +import { + LIST_PAGE_BLOCKS_AND_CATEGORIES, + LIST_BLOCK_CATEGORIES, + UPDATE_PAGE_BLOCK, + UpdatePageBlockMutationResponse, + UpdatePageBlockMutationVariables +} from "./graphql"; +import { PbPageBlock, PbBlockCategory } from "~/types"; + +const t = i18n.ns("app-page-builder/admin/page-blocks/form"); + +const ButtonWrapper = styled("div")({ + display: "flex", + justifyContent: "space-between", + width: "100%" +}); + +interface PageBlocksFormProps { + pageBlocksData: PbPageBlock[]; + refetch: () => {}; +} + +const PageBlocksForm: React.FC = ({ pageBlocksData, refetch }) => { + const { history } = useRouter(); + const { showSnackbar } = useSnackbar(); + + const id = new URLSearchParams(location.search).get("id"); + const category = new URLSearchParams(location.search).get("category"); + + const listQuery = useQuery(LIST_BLOCK_CATEGORIES); + const blockCategories: PbBlockCategory[] = + listQuery?.data?.pageBuilder?.listBlockCategories?.data || []; + + const [update, updateMutation] = useMutation< + UpdatePageBlockMutationResponse, + UpdatePageBlockMutationVariables + >(UPDATE_PAGE_BLOCK, { + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }], //To update block counters on the left side + onCompleted: () => refetch() + }); + + const onSubmit = useCallback( + async formData => { + const data = pick(formData, ["name", "blockCategory"]); + + const response = await update({ + variables: { id: id as string, data } + }); + + const error = response?.data?.pageBuilder?.pageBlock?.error; + if (error) { + showSnackbar(error.message); + return; + } + + history.push(`/page-builder/page-blocks?category=${category}`); + + showSnackbar(t`Block saved successfully.`); + }, + [id] + ); + + const data = pageBlocksData.find(pageBlock => pageBlock.id === id); + + const loading = [updateMutation].find(item => item.loading); + + return ( + history.push(`/page-builder/page-blocks?category=${category}`)} + > +
+ {({ form, Bind }) => ( + <> + {loading && } + Edit Block + + + + + + + + + + + + + + + + + + + + + + + + + history.push( + `/page-builder/page-blocks?category=${category}` + ) + } + > + Cancel + + { + form.submit(ev); + }} + > + Save Block + + + + + )} + +
+ ); +}; + +export default PageBlocksForm; 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..cca49f3342e --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageBlocks/graphql.ts @@ -0,0 +1,155 @@ +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 + } + } + } + } +`; +/** + * ############################## + * 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/types.ts b/packages/app-page-builder/src/types.ts index cee2c4e984e..3abb47d00f4 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -820,6 +820,16 @@ export interface PbBlockCategory { createdBy: PbIdentity; } +export interface PbPageBlock { + id: string; + name: string; + blockCategory: string; + content: File; + preview: File; + createdOn: string; + createdBy: PbIdentity; +} + /** * TODO: have types for both API and app in the same package? * GraphQL response types From 535724834b06f6fc72d87c834286f259c7bb87de Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Thu, 21 Jul 2022 19:21:38 +0300 Subject: [PATCH 14/56] feat: switch page builder to use new blocks (#2545) --- .../src/plugins/pageBuilder/editorPlugins.ts | 7 - .../graphql/pageBlocksSecurity.test.ts | 533 ++++++++++++++++++ .../admin/utils/createBlockCategoryPlugin.tsx | 16 + .../src/admin/utils/createBlockPlugin.tsx | 22 +- .../views/PageBlocks/PageBlocksDataList.tsx | 12 +- .../src/admin/views/Pages/Editor.tsx | 32 +- .../plugins/blockEditing/BlocksList.tsx | 2 +- .../plugins/blockEditing/EditBlockDialog.tsx | 36 +- .../plugins/blockEditing/SearchBlocks.tsx | 18 +- .../plugins/blocks/emptyBlock/index.tsx | 30 - .../plugins/blocks/emptyBlock/preview.png | Bin 1157 -> 0 bytes .../editor/plugins/blocks/gridBlock/index.tsx | 32 -- .../plugins/blocks/gridBlock/preview.png | Bin 1157 -> 0 bytes .../src/editor/plugins/blocks/index.ts | 4 - .../icons/round-gesture-24px.svg | 3 - .../icons/round-group_work-24px.svg | 3 - .../icons/round-home-24px.svg | 3 - .../icons/round-notifications_active-24px.svg | 4 - .../icons/round-record_voice_over-24px.svg | 4 - .../icons/round-stars-24px.svg | 3 - .../icons/round-view_quilt-24px.svg | 17 - .../editor/plugins/blocksCategories/index.tsx | 70 --- .../plugins/elementSettings/clone/index.tsx | 2 +- .../plugins/elementSettings/delete/index.tsx | 2 +- .../elementSettings/save/SaveAction.tsx | 71 ++- .../elementSettings/save/SaveDialog.tsx | 2 +- .../plugins/elementSettings/save/index.tsx | 2 +- packages/app-page-builder/src/types.ts | 13 +- 28 files changed, 676 insertions(+), 267 deletions(-) create mode 100644 packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts create mode 100644 packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx delete mode 100644 packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/index.tsx delete mode 100644 packages/app-page-builder/src/editor/plugins/blocks/emptyBlock/preview.png delete mode 100644 packages/app-page-builder/src/editor/plugins/blocks/gridBlock/index.tsx delete mode 100644 packages/app-page-builder/src/editor/plugins/blocks/gridBlock/preview.png delete mode 100644 packages/app-page-builder/src/editor/plugins/blocks/index.ts delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-gesture-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-group_work-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-home-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-notifications_active-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-record_voice_over-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-stars-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/icons/round-view_quilt-24px.svg delete mode 100644 packages/app-page-builder/src/editor/plugins/blocksCategories/index.tsx diff --git a/apps/admin/code/src/plugins/pageBuilder/editorPlugins.ts b/apps/admin/code/src/plugins/pageBuilder/editorPlugins.ts index 271390d184d..747dc74d66e 100644 --- a/apps/admin/code/src/plugins/pageBuilder/editorPlugins.ts +++ b/apps/admin/code/src/plugins/pageBuilder/editorPlugins.ts @@ -33,10 +33,6 @@ import formGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/for 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"; @@ -81,7 +77,6 @@ export default [ document(), grid(), block(), - gridBlock, cell(), heading(), paragraph(), @@ -113,8 +108,6 @@ export default [ socialGroup, codeGroup, savedGroup, - // Block categories - blocksCategories, // Toolbar addElement, navigator(), 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..879a82940e7 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts @@ -0,0 +1,533 @@ +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` + } + }); + + 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` + } + }); + + 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` + } + }); + 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` + } + }); + + 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` + } + }); + + 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` + } + }); + + 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` + } + }); + + 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` + } + }); + + 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/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx new file mode 100644 index 00000000000..e155b100536 --- /dev/null +++ b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { plugins } from "@webiny/plugins"; +import { PbEditorBlockCategoryPlugin, PbBlockCategory } from "~/types"; + +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: "", + 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/PageBlocks/PageBlocksDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx index 5bf49f4b479..1d8410cfad4 100644 --- a/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx +++ b/packages/app-page-builder/src/admin/views/PageBlocks/PageBlocksDataList.tsx @@ -21,7 +21,7 @@ const t = i18n.ns("app-page-builder/admin/page-blocks/data-list"); const List = styled("div")({ display: "grid", - rowGap: "8px", + rowGap: "20px", padding: "8px", margin: "17px 50px", backgroundColor: "white", @@ -32,16 +32,19 @@ const List = styled("div")({ 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%)", - height: "120px", - padding: "24px" + minHeight: "70px", + padding: "15px" }); const ListItemText = styled("span")({ - textTransform: "uppercase" + textTransform: "uppercase", + alignSelf: "start", + marginTop: "15px" }); const Controls = styled("div")({ @@ -169,6 +172,7 @@ const PageBlocksDataList = ({ canEdit, canDelete }: PageBlocksDataListProps) => {isLoading && } {pageBlocksData.map(pageBlock => ( + {pageBlock.name} {pageBlock.name} {canEdit(pageBlock) && ( diff --git a/packages/app-page-builder/src/admin/views/Pages/Editor.tsx b/packages/app-page-builder/src/admin/views/Pages/Editor.tsx index e45d2db6370..ba6fe2631cd 100644 --- a/packages/app-page-builder/src/admin/views/Pages/Editor.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/Editor.tsx @@ -19,11 +19,14 @@ 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 } from "~/types"; import { PageWithContent, RevisionsAtomType } from "~/editor/recoil/modules"; +import createBlockCategoryPlugin from "~/admin/utils/createBlockCategoryPlugin"; interface PageDataAndRevisionsState { page: PageWithContent | null; @@ -72,13 +75,30 @@ const Editor: 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({ @@ -133,7 +153,7 @@ const Editor: 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/editor/plugins/blockEditing/BlocksList.tsx b/packages/app-page-builder/src/editor/plugins/blockEditing/BlocksList.tsx index 3e4556b823d..6bf40144217 100644 --- a/packages/app-page-builder/src/editor/plugins/blockEditing/BlocksList.tsx +++ b/packages/app-page-builder/src/editor/plugins/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/editor/plugins/blockEditing/EditBlockDialog.tsx b/packages/app-page-builder/src/editor/plugins/blockEditing/EditBlockDialog.tsx index d73fff39b30..6c3e939bf22 100644 --- a/packages/app-page-builder/src/editor/plugins/blockEditing/EditBlockDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/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/editor/plugins/blockEditing/SearchBlocks.tsx b/packages/app-page-builder/src/editor/plugins/blockEditing/SearchBlocks.tsx index 5d839e6e82d..4413597765d 100644 --- a/packages/app-page-builder/src/editor/plugins/blockEditing/SearchBlocks.tsx +++ b/packages/app-page-builder/src/editor/plugins/blockEditing/SearchBlocks.tsx @@ -24,7 +24,7 @@ import { 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 * as Styled from "./StyledComponents"; @@ -72,8 +72,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( () => [ @@ -142,7 +142,7 @@ const SearchBar = () => { }); } else { output = output.filter(item => { - return item.category === activeCategory; + return item.blockCategory === activeCategory; }); } } @@ -161,7 +161,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(); @@ -173,7 +173,7 @@ const SearchBar = () => { } }); - const { error } = response.data.pageBuilder.deletePageElement; + const { error } = response.data.pageBuilder.deletePageBlock; if (error) { showSnackbar(error.message); return; @@ -188,7 +188,7 @@ const SearchBar = () => { }, []); const updateBlock = useCallback( - async ({ updateElement, data: { title: name, category } }) => { + async ({ updateElement, data: { title: name, blockCategory } }) => { if (!editingBlock) { return; } @@ -196,11 +196,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; 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 69ae1dd38e8f51fe69d0d67ebd992b5446c7b008..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1157 zcmeAS@N?(olHy`uVBq!ia0y~yVEh7PdvdS=$ZyV_DVP z^*T-B54w1<*E^j5e&^%8mp0E^nahtjH!v_Vv2g79&h&V%zXF&opdirvj)Ox$!J)yS zvB6PaBaj8CLO>zm!d-~GhJe8fR@E*CuvVU3(m;hk305Yyy$f6f!TK5;%b7u192Phn zSRg({g9&5@P*)zrHW3bqFHJ(O5PfWSc|le&G%_(Z#|13n0P8!jU^iHcLIBWOZZDBW zu+0)B<`7G{Sa_-&JC(rt7@Oa*4GG8oh;Ll{H(K(iQgKddvd#IWllR8_{xd~_k*O9K za_fUTIp%Qxsre8(X>YkjT3pKSbIT=@law45cpq3${#BE=vD>h|%<}Wq`I3*D&s5Io z{itDl|IhEjXQ$u1sq61zXw+kDzIS!G3ClUwKEAxPCy|qupZsH(Eb~0)-Z=?==FI(^yJhzbM5?&eeIrJpPqN;^tp8V1fW&^2NrxU z)e>whXRW)tf7bQc(I?yYr&s@$w_)KZ;ga}rE^et3DB6#*@cc?@?e+#Gjk!QWH;1nT zMQ4H`WAna; - } -}; -export default plugin; 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 69ae1dd38e8f51fe69d0d67ebd992b5446c7b008..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1157 zcmeAS@N?(olHy`uVBq!ia0y~yVEh7PdvdS=$ZyV_DVP z^*T-B54w1<*E^j5e&^%8mp0E^nahtjH!v_Vv2g79&h&V%zXF&opdirvj)Ox$!J)yS zvB6PaBaj8CLO>zm!d-~GhJe8fR@E*CuvVU3(m;hk305Yyy$f6f!TK5;%b7u192Phn zSRg({g9&5@P*)zrHW3bqFHJ(O5PfWSc|le&G%_(Z#|13n0P8!jU^iHcLIBWOZZDBW zu+0)B<`7G{Sa_-&JC(rt7@Oa*4GG8oh;Ll{H(K(iQgKddvd#IWllR8_{xd~_k*O9K za_fUTIp%Qxsre8(X>YkjT3pKSbIT=@law45cpq3${#BE=vD>h|%<}Wq`I3*D&s5Io z{itDl|IhEjXQ$u1sq61zXw+kDzIS!G3ClUwKEAxPCy|qupZsH(Eb~0)-Z=?==FI(^yJhzbM5?&eeIrJpPqN;^tp8V1fW&^2NrxU z)e>whXRW)tf7bQc(I?yYr&s@$w_)KZ;ga}rE^et3DB6#*@cc?@?e+#Gjk!QWH;1nT zMQ4H`WAna - - \ 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/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/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/save/SaveAction.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveAction.tsx index 161707f8afa..e31076a3798 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,7 @@ 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 } from "~/admin/views/PageBlocks/graphql"; import { useRecoilValue } from "recoil"; import { CREATE_FILE } from "./SaveDialog/graphql"; import { FileUploaderPlugin } from "@webiny/app/types"; @@ -124,33 +125,61 @@ 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, ["type", "category", "preview", "name", "content"]) } - }); + 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"]) } + }); + + const { error, data } = get(res, `pageBuilder.pageBlock`); + + if (error) { + showSnackbar(error.message); + return; + } + + 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) => { - - - - - - - - - - - - - - - - - - - history.push( - `/page-builder/page-blocks?category=${category}` - ) - } - > - Cancel - - { - form.submit(ev); - }} - > - Save Block - - - - - )} - - - ); -}; - -export default PageBlocksForm; 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 0000000000000000000000000000000000000000..fced388673b754a70c5391c8d009b92f0844a4da GIT binary patch literal 18962 zcmb@ug5@vt9Z0|0>cN=`-%0MItT|GQ#gfWPN! z`c}Yy?!T4OfdK#x@!by!keW^o0Cd1BndcfFGduI{K2+mP5;sFB)xLEj#d@*qr}*`c zDc){05z1FGsuQCI-gEFm%?Y+}o8-6t5FGr0Fm_H7JCKmAw;(7A#mvsuT(5rBkg?2b zf23XrA>qdOXP`?i=`zam+A!->;@5CW3eQlw(2!{AJdvc#{~s+&*i(JG8??bDUF==zny=Wmnw%5;74(dY`1OadpihI zUU;OM-ILQ9q~&}3YEfNT$H-L{feO4Q^i9y3znC0Z6i*+HUu(h!avuzcl%Ee(D`Z`a zA8{1!!T>_8VUF{Cu|bsXpcZb^BEQ~5^4t!pue)@+Vm@rOY4 zrzU97u@<@3S^*Yps)M z_C2@*#Z*e7+M2gn=*>iWX>4V|3`S|7G z)2hs$4|V}Rx2mbj+K##>9j?2SAMOq+8e#zKhNtDtLw+WGNq$Hq48O324E0OS>&dk} z7^3kqmC4Uj$Dmk@7N{SP^FOBz&C9X$N|jGlr%K_H4bw0$KwSN~4D}MopRU+=tJB)i zELm z2ltOC()Mz0lYE9pAxV4QXF2GUhoeL20nHj(ihUtQK9_n3kF!$aa!W2?r&oS~-(`>w zDsX-AG2+DX$pDGe_c9(efwLdQG1UF9n|3^!83f8d|29r^5859$pdODK6`xFhGeA|f zbv+&zyKwb;83$JU+3;M z3n^LNe%k7#c(0-_y2rjw`fR6JwDWVW>wlIMid_{N^Bk;mTxhcx7-_JyBtiYMw=+iF zVQJKKJc~FR4oPVG4Eq#N<-7K205Ln`@#p&ELd1!({9sJckMDz2`)e&mQ#1Mk?AzaF zG_(e@3y$_PVh^MJ=QF=%2rVG>V(lsZfmd7G;LB-0$x>*MKDs%kE-V_omdDXI+dgU2 zxxjS;B07_bS?l{;l$WzKZ(XrVZ+VbW?k5#}y^g3!38U86)EhD4a za%HDvvCV8C6(%q=s7*)uxZPL&MZTl&_yT^za_g`7_!LzG%7u?{`kUTW&Fvgwn>*Jj zdsojRCK}wGFY0OAs|yQfZ1ptLwtj3nX$#|9cr`2-)hq60WO}}cm`xl`edAcHSmkis zrm;MenpI!hW3K4%`gn~0lhER61>Rb#2t%InmtrA;LMGZh=jlac8NuMJp!Y~FoP%v# z$eNGg_tvRqTS?}@VfxOfsTBFw-i3L@%tU3LU*&13yXBifO4YjGTfLcEw4#V>D}S3q z3aGleCGIp^n>Tk`%N6IomBgMa8TI#**PBk$GXxeMZWj}~@SUvq)=z`m>0|$(tJ~AQ z{_)(_-0d(|Gls8?V_au;=`FO(t4(;h?J7P*%+2;NkZmZ(FzIol^KNvCo`vNQWu?vF z7ITXCOOnSMdbIl0PJ5q(I#LVnA=OXj|4a;LjACGrA?lyG|E|o9)=+*myRWC^n!RYM zbG1-<)P%;br>vN0c`IG9Z(m%)TBx<2Ke*wdI~)DR^JM;3YSmcU;_w~XbSAT+gRR+9 zv8L_@htO(&wZm573YeOK*T?}`sC~tLeg?A$a@3-sxpWiLSq=Y0cwDT6HSx`l*bm+Uq!lkxEk4G8r% z&d(2#h6W#CGXVn=>Tww^V-0?m28a&s(>Ci(m`KJ%J;UZ;Ui-sS7Xk0|nqp+EC9mfW zUJm2w$8pDnc52y?~{esr(d7PtYw3rN*SEQK&(>6198HxuPp+)w z85I|c2)BK4t&Bd^?{6towv{;Ze_9yR&iu+Z{Z^Lfe;_?jwV}3)1Rd4AKH^HH%Kq zczRm|m>yd@Dmm}QCbs`(Wr>+RUHkNAuUBaV-<(Mx6H(!`(wawIZ|vu_7bv>_efW)D zPr`T<1J0I+onC>Z;(XTjuASV-WqWfzf9&kEttU9jm9|ay=5QqOUtQx}n(pNkom{+o zg>+y0L`yzAK2@w$%!O;^*q_16Bsi{TMvF`7?kJk=(qvcfUSPQfRaO#DALV&~Se*~D zc{;FL-mi=Vu@t9`&e0Ha-${M19Vah1Ke5 z{=wefqF%T0A*;Rda)OC&DQkyQ5p{Kgm(`*JAw42>eW(1L_;0+`8We|Q?8DoTHJE9~ zH3W6C?yChB_Gy{IV<0xx`DrVmvK$6AAwGuvHXRE4HZs1e)v}(l^IhNHrxtHs&+>Av zd$5spH7tm|QP_^bMF@G19GzO^)4Jk^lr7Hrh^}FUE_1SHin1rCIkV*nES+_WT!v!s zDSDd^$G_s6F2A6#myX_Upr+Y+E3#8D(054H`?evSW(yX-BP_15?)t<&Ijt(rjaT(6Df}K=hlMoWgVSsCDkw8%OC0$xdHnqO^44 zThW^#S@lDk!&vD!ko-cll+xf@<9+YW3|Dm6D3 zi!XtYne}*uVD5(UwtnlzGsg+82D-d9qOi52$d1kOdLec+ib+ix`|XCO7d0U~28y5S z_qyt+Q~FMM>-oLyqWw8ipESoFeTCuISr~d$=GXC$uX>tn*5;rn%5auh59)fAmaR&b z3mi9fd1lmOjSWrmej0S#u)|SOM7Gz`6|%ZcRT{LmEQptsHF%Hi?8G-%n!8vY<1rbu z7Y={leMF^=c*be|vb{kB0efbDUVd>CpCfnHls_6v@TS?@qTxib*~Zd7f-IqXKWRY8 zTE5OTYho%*uQ{(iXVGLhOBj~8u&NQ7NWK4~&C0PTXL{M?>bx}NBauNj$JC_Y`CK0L zanDFx?2YrZleZkG?rh4Q4AE*JVg3OI(l}BE>I$d4<17b_K0Dvgqd5wNYirxRXY$97d%WyZSHqAG$r+j>GyIEu zFFY~RqWKF1cl-st*DBdrYGL?%>l`8zy57%3dN1sLk5#%q+tYTdeVm_Np2sD~F70pa z=f+zIdkW6v3&ks{N_;!g+3b=1{ZmKfIY&G(^)D=IkB&9ieoVTpdYZy!FJOb>V{<&8 zy*>VH@>p8cZ4ORtDz>R}7Fk;#MMlcdNccxj{I2iDmY%QSoFDbT0%+Qe%Y{v~%=+*f z2HQ3y`))W2p}srcgq-v=tSo74dnNW&`aX7R#GG|h`)pE2iePWgjtmKpXf}AOVgJB> zVy3n(C;wbJ$@djFCbhtvBcpgmOf}xqQGx zD0F)jQn4%!$ZZoi-mA1$AvSW5ck&|wxG!GZ;AR&OIh-r$#a@d7-?u300l}&5+PTry z+J2OP(uI#+0H<4H*^PM1V?1DI_-q@9=Jleoec>XTMCfEUbJWwm*FE-g#o88-@iGdKQ|B5L^6yXlwZp5RhXff&ORNq@|sE#Z?g# zfR4|glkW z=m4SWF%!y8w2e0Cum!{L@2QjB*;{^}nXz$51uH9u%ssd1>Dk$q7RPE!*YDf%QAIxh z4XROIMK|G6TisZ@Jrjd%nf@ke)%XzmR}*}`{PXO9z=48Z)?N!{)JSPHe17}z0v$ueG_J>7qKaW zw+6JWl&{y%5uw;{GQ(aMuxgOKsSstj?%z$CgKMLU7a)+&f*?L@23J#(Wj(3CxN?0K zB|mIdLNlg&;M@H9v$ZQ5gEjQ~b}E@xCzi`<5`JhNeXezhl%bZP4D-i|IdzG{+6eyB%|fH=>E=>+V>>-_#Y6xq3+!@mSbum=?>^Amv)Kj41q=Mpkw0 zNXUK>*E54zjQ?2h+pr_h*xX@`sGqlY#hV%nQZX|kl=7+QGDBGXEYQhqp2AzN?!#NY zmXr>MIq4H&Xt>&HX-#UGjBAComZNh)-b2PG?f7T&#K`yvL8XL3^B;yEq{lm(X=vSD z@macg20x4`gmBlypoIqnb~1Z}A$R>zn=O&gYP9_UNS4=bqM1rm!hy$Cu6Rosm=guK zI!9>mr;AV$kgcTMbfty?3bJm8v|L{uEFvo<=YHB+YwYa3swcA!L_tA;A9|Vuiqt&!cIW@j(<3)P~fBq?|>rJtB@tF{vZ;)Yx5|i=9NQY?|hOS~9sNBh~oH94jN6Kh0~WGi=9% zrX_^A(}X|FLD2o;1|q1R+gXC;2)4HoccmFG(oM)8;_Hm1fR`{z&I?gRUFl#s znPowT?B$%ifVwAJmDjJZoL~*oS!D9{p98$eRk9vP&5dok^*hK{M*}tnuUY(wrSuo$ zm-MH8SJ$E0D6k2u6-XKk|8iyDs?*t`>jWiHZzO;G4diCwgMkDtgJ%BIa5Am8UO|(5 zJOvStthO)!1=G|_t*oU~+@h9m2WNd!@IDOt#C|YXq$;PfrMe-6xs8kE>wHgyN&~#! zOc9bw!Ix-4U3DWyY0$>J#*5n+gBx4t?bE=;!(+fkL4TMZi|E`gqNl2|Cx;KPX&^LZ z{Zw80bz9i?doh)i{shH)E;>?QHxK}4fhlKj_3X3A&+k=6C}ZbGQCziNk2l{iBb@Jv z$Qqi>032Hu%R~;VPN<6uO@#oz_`n1bELP5PGQns|mQAzoKIIicm|2v|Sm&MYAZfqj zs@oh)xBxFGa1~@jRhh*sop6B2{P>{Qw^yeZ3e>r4Yhi>C4jQ(Io6C*rqL?%0VoPJ? zxHMDqEMY|7hbE~F+eyiU>c#Ey0LOBj7IiG#G!LaZAH}BM^$`X&_aQGxWUU5h^f>EUPdpXeSj z%5gmnhBp)lK0&kbjp;~b4Bk+ut95G-qgF{1$#6>}G-WLRGHhNmg;H13m#-H>_`8Tj z^qRi}rRaKIbATh593Nb=ATroS8~YO=dx{0|3f-A_rE~JYghLCS=ztsU_G;Z7Ag$wy61^pzD4f5ft`)-BC=};k4g1z#k%Qz%>W5-F`E9#=hHZX~3gB7hRrD zxkn)L!WsOS+4yzM)J-33;s~AcCiA^P%LC?pq*A<+-?-mET_maP+;yFU38U1A29v|F zvR`=5NB2s>FNz8nQEZIHu*-%VddZ}M-$QdyZG669^kkPa-3MW?dr_&3I}|<^DqsyK z%q^L;jzTS7uo&gRQ^dUwxwuSE6wEFj;PB2=iRXvNG!8kaqVO%mE~RpyM=QvFMAPg- zDGE}2Rlg|(&r=~$&ssnVvOw=ChbvI+TufGIzAMSp>3_;CuQ>44dX&&WKMpKh@a22Mc7%~-Ye5^G(~j_3dG zD;_ZmlqYri-Y6^gN3sqp7@i-x)0ln#_nGi+>z^HBxhAu2b+M8kLmwusetsgP&T^;$!g|X!F2cP0_;55e@(b-MU_a#U>$#8c(B<2 z_FpVSiotWZflAD)qht~J2?uolKI0;ua#CTb-0$#VglV{!!8H3~TPTF#-}fFB{=!kn zX6f-+ZE4TlzJL*k{*hdM$wxi;a&B8ay{}$T@lyFk;(uh&*M<@$;RF*-zQ`LsimO{u zSo+T+alHI1k4MKdyDh(`j?}jG*;@>xUm1d2eN_nin{X2d`i;KmZomYS4(T=<5FFuz@|800Q7#Z0|q4O{B z-1{oEv>gsv1jg2EWU#DS=KqJ6?Z-|zZ%!{6Grh~%ZyOC#MnoAIkSkPr8t1Qy;FZG5b@_Zbxa zgG;m&3YS?!lZ)tCzF^|`8TRk}8@FXnq#GizR?6AAldS)-VbU5VIP)of{p|W?E3S@e zQg3eG*M5iyh`}KLKc5a**{+EILBB)dQ{<2YEhgb@QNgm;JHvHXLmx&SYvR9_QD*y+ zO!i9_e*gY0U~6MTmRY*lh<$V&7mHj>%@B+FpL%3VyTAWzb>gE|&P&dfS(1|ZYbz{H zjo&7f>}Ex8rpfOKOW&S_+>ggRcGq9?eZ23iQ>&h@g>8wE#Y+)i;>Ytw*OM&o<>4ekiD&X2qA)&oLq1-?g(TC zvsz4@f8a;zkM9ruy-YEH#IGps=(WiIodrl1$t+lA`H9J{YorbJ{u}Yzgn`t8Yud4K zC@BwNX=Bg<hpYBK#Vu=TU-1BcF0UVxMXb(W z?zL1P^HQVtA3LR#zq?L(Rq~gS`%_hWqW2~XQ5prP@Ygd+H_r?oB<-af4)(upXN>+& zNA$Sobc7i^ps5SEcj4#^zty8&`L2O{s||GQHv;oK{D zoGfdo;e7cY`xU#-02x$Vb|RwT!lA)xAK=T@Yc_1MS> zeI%h8=qD;>ZK$p8i1LT>MG|;b{KJ)AIIGe7c!B4}L`!Rj#zJ&eI8vhM;~tM96uimV z=;?FSOKs*u2pPOrntdm&Q%}eOV%UCyF>MS=+3VdO?Eh~028_9qYb<9Ao|6;RhZrg2 zVZ@cbF%ccDtUlGt$|Y}Hau6N!lhx~IwbRSi!sV!9;GW0v44dm+jE9*?*u1gTu23Zp z1$CLw4pr~<-FAKfgX;Te%Y_0^@vr8;V@bW(mzFO1*PSJEC;Wre+pbgrh&Db+;#@x+ zD1nUFkycc~fk4S$W=0PL%9%E99+vF3AB(^!wC_9s#dsA*wl39<2U!%i{%EM zM#cr3B=HJ6B>&~L->n%~wZ0tPWhwDd(B|N0E=2_VE(L_qgFUuBNpL<@Bw76G>=|o+ zb^nV7MjS}pVrlgfz0A_MhuNtb6gtmjD-7E0;TH7-cVCmg-3jwL&7M>5-mhDF%=NTc zP-nUROo86UU8M)S)LMalf@0@yLr2{{+*!G3U^k`}^- zp0h54V2>O*f8HK{c3)zzJeyjTIum!(1I2lCE11+*2d7l@L)=dF@PW^5-x~M*{N5ye z8{WeM#zFV_Jk zmY=7E&DHeH!fF5DA}J~stop<8R7q5*f(kv(mNPJCud_sClvH+D zdBQ~>{h|t2%Mkp4-v6)j6jS#CkxJT6@^2A!@+2ElXG*&9lvAAn|1iV6WuUUu{tpv| zeC%e*b05b4kpm*-OZk69NVOX7H)UjG4986zE>C}8pt7#-QXTy8&+Vj%iD*Ta5>sj! zP3$1E7pGn{PGwa!HR_8Ej#+1&=oh;c<8Q8ijc=_+vfhb|?q-!?m#Mh;&voDF2valO z-97$!m1pjWkz{^LsMp38vF`5W0tx?y9$P-^-3Leim7)BjTY^P}+qXg3 zB=O>1l6Q(jO;}AbQI=ivNR0*)HGQ+!!%^3Z?5?`Hgg?jP!8Ih$kq|T*b-z(C(i=F_!cHE z?sHx@@`DvzybYFZsdoEQ1ef{FBaRUq1;?QHhd}ibV~8jEr&b^&zoANy36yLX?y^u( z?$>o>h8uW3Mfff@%0Cgct5(&7OZpPhcM!Ko6C6G6N^)hQnjVX1?Q8v0*X}TSmb$Kd3mS0yI~7AsjK4t-nH+S9(`h^L@tWiOC=su}n;+o8V3-(oGr-?z1jOhi+e~Tkmg0d& z>mKpL4Fe8)a+OeWt^eE%(`#~Gy@|2qDc{dd+H;U^_wdgPg8Uy%K;Z4WoU!xkn`2h3 z^7Pv0jinP^5DgUENLsLK0!;Z&r z9$BT>1IG_7)<%X`uUeD6(--e%a-)yI;={!7G?K1WisyueU%G`ah(&fGP{fX;oQxVoUAAjZ%gY{8{yqxxdkVo4T_2l_)|yE|N4*W7C#Rd*u~z?ozn!g zE4P3BHB&=WI`!qR{ZEt=BVeB8BeS)_A zF|PUcKLUCE&}7A)1UD|4R;_@!=IfxY+!K2-`2Q#|T;AwQ>{7uUK7}Rz!Twlje6h~I z<|U!iiXXOK9E=PieuScfWv{#wN*nl_H*L;L{drh_GE1>hZoOZQ4~)YU@>zF5Flkq z$PgjL;iMnVUYmNKv4)4co4)MQGBy?mBp)KWH~V53v=4j%ApnSI#whe{r~DcY1=4|l zW0Adgw?61lUkR?u+0b`Oz`N~HVA&9sb^Mg==5aBLh2#?DTJ`)>Hor$AEZ8Q$Z~y?> z8r0RtTG@6bkquWM_T`z#D{>)b>8p50Xqm!wi<6kRCslbxCfSb;$VBzL)wE`>Pg=Y$ z`-Z9Bvb1|?gytTrCfZv7z>e7evf!`%%=G#rMFZ%VPRQwm#h2onFH}0NPub2%>&r6G zq1UMtNE?s>iUe~7NWASpV_Q`2lIxU?6VX!RJs>+9RUv%wdF7G~V42G0`3SwMMHTAA z%DBdO0`Uv`wt8Gt+=%l)2M9=}`I3@!oKt-T!7@RkQ`Poi!bjN2Iv#~u%r&cOw_!#CF&&nXf~1VDntu}s=kq;|7t{CoIhvG0yNalvwb;QPI z8E@k>{j8lO-tHpEd)q}^q~8KawM|FrJq{1gQf!5)vnx`uEPNSR(yFrGUC$|gm~6J< zNy4@mUL3v+sp{P6*;n=GLeWU@$5DH&P+B4unV-~Zxj6sP8ZjNo&tp3?^Z!s$4L>Tg5?fifgi?#FoDeEIx$M z$%YAO8cF{?oK%JSp??xw6df8e877@o)p0`$uKq4M!KA9;L-@6NACUK>Upr$SWOI=bw2d;Z)67fKqFZ03p;lFxdux2R_9 zE@sSOD`m1dR9VAD>$ECj(SMo)&UB`9>amNL895T>>2>RCDxWi( zQb(e{he_3f<6030N&FS+?5z-9_zgR3+7RsB|N1Zym^rrSU+fqmwN=-}d6jiLGsmy!eM>C@;*B@rp3fU^K+o6CmUO zB)=ZER9eTOHl*}@8ji_TyS?A^`VoXALEKAfkF=WxoKS}pSEI`DoPP6o7?Qs_rFx=6 z{=@vv4OU3JxkdB^4q4Kkl=-w4B;Iqax~HEL&<$5@F5xKFcqk`nT=R}3@~4A4HOJ4tbruv{w3nezi*SZKSx#S(%mDeqOdi8~4; z0(cN(p~lk$C|%fcXd#c?f1l~`yPSxxO#O{w0Xkz4H5h4^Z}9RS{~N}vX#t97etIG=(M`=JC@^*^2ZPhxT_R|KYX#R))9 z=}J_z4{_Z}C2-z5B{c1UV_$0|^)^QcRus?Ny$w5HQ%*b!M@ zPF#^W-z$=5D>`GBELC+5D6mSOncZMuha1UOE;$1aQ@L)L2DJ}vK%kws0q;mJRdsII zv3QaoCs^1Ob}!7&uL&(UB7$kZRNKB+g&wHO6{!$*$1Yt~HhsgRyt4401^pnl;~WYC ziDD)Ii@Q713b(XF&q(Y#p}spmox*?ha^EY zeJD}YiI*QrbbVXWyvIrdd_V>MY@C{`nVWz;6gs+9tZ@0go{6NmY*}B6NL>|`t7q@F zpP(IjuT`Ug6I`)k7Nwxhq=95dS(5|gl&TX4E(z11m|@q_l7Na%6&31w%H%5SDa(BH zk3&jGzdl*aExu)`pon z4(mW8pP6+pg9n0*wiyiDXLDpE*mUET zoDi`}jj#BkeUjMqKD_$7Qu>kIB=rHN0PdY1Og?xMUc81iWv; z60Qht=oR7Whum}or{;K^3@;e7|2 z$IE^*hu{w%Wdfs9J!CCF_i}QV$7}|_Ltm>KbHFH>m%(8p%q-pn)&h}ZIDn;*2~>pY%5|VV4f8*3 z#Ql_J29>3!aUu%jCR3TRbT?K>03?mpuR6T_q)6LR5f#_pGhzJL--UYf82i>E|#$mWMuG~3|u{7-VN zCG-WyD5|iOVS9%>TeidlkJ~9e5ic6X2iGAp_bAl+4<2&$$YH5HiRRJ~ShgLLl0ug; zZQqcly3n3`a9KOm0S|pSf;_m6 zq8uQU0stzT&U7BBfRBPbLA&ppZkIp2M-7vq93Z}=OUyI-i6tFAY-UBV?I?G4#H>sT;<-T zaNMYFZl|c8rQcPy`s1ksz68-x)&o=ogtVPne3z_0&!6|#$@rV);+eYW*qupV^YvH> z+C(>bk#nm`;vXqs0SRWa%B4*DRRkHMZb9)(Jzk<$EuM_U>p5{5JAy{i9IGq%#-|dX1ge+S~FjQ6DbPuGjpQ0hx6&hcxj}Pnf zQ&Fe+cWw9v^1rjP<8`p$P{-)r!y!5N+V)M`KVhM2s%K!E+itK<$~usD$86n!H;E$e zcAxvQWP+ZiA#EAkqr~VNU$Zi9WJbg=j9}Q5X8)P#+F4Wztju)t_9xY(5WmGsv->6| zTD1%oRJ4@HylcLt`YPMyAn!DpQufSbPc~P=Z`iWmt8A6tzu3pQ=vj-c+FnR+`9yU= z_cDwhCVJ%mOf#w+#QkDj7q$>Df2l1}B5swLM)svL`JD4A$2%maN{EHa!3Kicr@cpQ zC(u3E#tVy)jxzQDPrmb~nK^AppD(mxFkBO^wMCV?r=Akf{xDzj`0zr+IFmDi-zqxI z;>*wOwUf>p%5G;zTJi5~&s?yOi!^-EnNwC04`u~07(TecvUEqaeR}o^Uo2DZV)i#_ zst(Li?!Kp@7Vuicyrvd=Kdf)T%j8S~Nb|rd9)S5Vcpw>Tl9>i75TmQ#JgBWZbkD50 z$NBfvb+jkvlY@!_(rU*Ya^p4b#|t5ig5qwR9|a5TY4wxVu4~^tA%FjDJreGTP>pHa zp>7GK@I8azk*d9_tztg#?=@Ftz>Of!SblKLXBuR-ZU~jw*q>?*cR*fjydxJO$ZKlY z4QiTAUnag*q_XknwaM(bs1kZ6N;lx-fvd)%(toMY^5`X_XtOd;Qtz{|s^*-ox%FS6 zhh3r0L7t_md6sOn(W{Syyc|b`-Soee!btLo4jQ@K9L0nuXUyZRG@*AFtd?gm_ zk}9qAZ9mBjd{eCDF^%+;;6diIN7NK87D}2gZ8n~})Y9gi_8tsYUE-$Y-!g2=$?|`D zwk;f=Iv{#*ZvOpkUMYq#@s&m=eyVRs0!1vjdX)+z$x)shdv)1YX9 zU+UC;WTAPeVpYw8O9<^Y)srMSo$3K`k8_b=RS0V9;4vOB^IM! z99{mEJssiHD)PLYGdMXz&~liMK&WYkKuntC66+cJwfbVzz%Oy&@}M206K}10671vI}4>4{VpfQ#*TJ( zcI=pgJ?8W)hCphLe@YXWgL}JBi^b@3pZ2U$aQCy_gnW^&0ZM^&?*`m#7(##B*0jg! zhd|7&Bg??kb{t%5Oa2CJUdJsU4Vuiv2xd*_@o)mG)*it<)Kht8iR(_RRIvdoX4 zOzyigmqg?9=c_HLY|~HuQ)Mu8cc==U`R6@~=%Uf7u^9L*Nmr>RpMnEumKxibJ{;soP4$coIWP0+Em`cK2 zr`k@T`c2#3xLD2Q?;X{b)kmLwHg%Dgx%s}9FE0CckXd2`^Rp8J{rx_jj?c7hOTc}Y zA&#Uz>X{qj{wr1b3tt%@|EfXQsilsa01rY}X5mx&wFr@KTFN2|eZ|k_ER(gY2g7yn zGpV{`E~F}bHN2~LXl`z>Gcz8;L%E$eEtZ& zy=_3s>4V`2qJuWwJw#dEt-MUd^G<;xMf$)<9^0v*W5(cr1Up+GdBaemsv7L9Ajy` zo|*e4<2IPd=hF0tdRz}flBMq0^7+7qkViD(MHa(>ged`s08`PqbD(3NDlg$cks!I0(1BoHT{J!58}Cu zVczm-2(O}ldcmf56c^`2v->mTbNZP;_>k)22>4i4+g*F0>+{z{cD-^6a&o!WM3gU{ zw!0@8BJK{l+S-*f6GRs6b-HEk&tApaj-Sx+_$R^*1L2P2r$_B0^#4GTeIt< zyw~@65Joab^%y%<6XLIAsNr6yXg{sX#9qqKKJRobHm9Y_<%4^KCQ>v^ER4+0`+C+) zzpj(OCmP<>NxY7R?L9g!%Sw^3Eb}D=uG(hWzxFSJf9OUHTs2*sH1R54rkrQVuDC-j z-L*=_AFGfZaf9C#Ph0J=};8=fHriF`?j^zxHmOBk0KfXC^t-hI=Zm6bufhi7cf z#>vz&^;Ivz$_1xibY6yS?C~lZ*++WD)%$buEP@tsfL3Qdc7@oL37a~v;FMP~9HY*& zDd(a*`w6CIjn-xJ&GxhPg=GdUZVibP=4LLAM zgYM~e{}r|E?ko_l&PR04%X1qE7l*^OHmE8~T3RlX>k?7IXHCn>%4iqbeJ|0$Tsa2v zP0HFFCd&;F+!7N0CG~vY`V--q5%AgZ6LOx#S2F%^?R0=)3Dg6Pg7F$4IscnP^p*{@$&ZO{6UdqJuPHwZ|UxST9Z0pF=->j z09378rJJV+9v`7pRSk&f}2ZP>;GnO1u~FpxcA8)iC*ZJk1= znJRr?sV(DLvnjiTTHMeOwV@Fil{?*j)-5}ETV$TE_PMa6WIMR79s?M{g}ty?V4{x@ zT8^d`nP{l3-5KF7Ocr9uU56|Um<*mN=-wLcrh8i^nlD~UWMqr1)1U=-1HYUA^0CfU z{aD6pan+Igmfy)oE5_fuK8soL+(WSY>D!|Xl8UtDt5xxY!!I*f-9A2HVuB$6=fRv| zg9`b4wTB=5?9KX_90GTPFJKrz>u#fX$16u${WDXhcZ%Id&Grc1&Z+gHQ`5?@N}jl@1u5!=xt;)f7*aw+442@QG3qbT-bN@|F4$w z|7SXZ5Dq^<+!3*tyyAjM�EIXt$)Y$6vG zl?w?|VYo5b9EN5#w(=$UvatDbpZzaNk1>-B!TACDI*=h0KYXk?atzeV)L zi-q$j2UN@k0F6cux3p{`%c@#yF~)!+27?)qNF+83(B4Vyd}(0E9o)k;JDs`>>z!jp z=jjF3S|c9q(qVlUKXaYfGLzrjz)5beQ!aSuJ0X!6m409-AijLgl@A62XqI92c%_Iw z0w@x~aVpQA_0@BNLW!nQZzROl3fq>{T?GZr&CT*!V^Pu%!lfvr3Hb)*wpVHbQt~-# zIkq(L!()Fp&-f`W?SyU~0Jyya5~*)O0AXL*0}Jq%*#$+qENQqKfitz2-d$-74V$ZE zi^iX&r$c7iU`>v3;^k-f@wOp+{UQ|LnP*>kxA%nteiIdI4TP(NEND|ZkdounT9G1? z$h3E)p|a018_tu^M@qDbf-VZE&P*)YTe=%LERdiNpHbE2;$yC+6nJ6 zHN=R_vu~?dwl(PIUB=g@@Au&*FM8)J6)JX6q_Ys~|4Dw>E#0>C=c@6j}@$*H>Y zAi>UW_u$~*1PC!EQFv+u-56@CG}10Uo*7{?zGwT~qZFy&I+wrWJnUl(080d-Bgx&2 zBm@0vV|8^kemEUmaPD}lO#eGk#5Z!Dx$d8u)Ik42_3~l9)gh`CNc>K0yqD_gaS@ky z;7M4M9J+b)(NCdW%T=%Sn#e_lCZI1td!nhFs85ho=d z7mX!T+&kLaj|xEXUUNj}?#j~+79|-7?^2&k1DDT;Set0C{^H=&q8-X&@t^HnCNrA@ zHkB?oMMyhA$X~-Yaxhk`JfBi!67uuR9X=mWWUQ0xD$6MLzdp8#;?@{sVxaCcUG&_ocB}tm>7p8>?=!JY#l{-x8xdL?lJ`rLFFvbcYc~~uraf* z9oes*(7u#6q%mPm0-H9W&}y=;BDnaRxUT$A&^N0@vz9*Uv^3YPhOw5Caifr@BsnN( zd?z4Z=W|+wbJw$A=r#` z0*cCpIiH1J)HESD53CKvt#%*V$1K!i#d2xUxA#_E zU$J4DJAqyVUn}H6pN~H89x)j51WCGQ)hmO~U!fr)nWJ31lY_Gn%;be~+n$sA*t@YM zPHL3>JXZDQ%^TnF`n^$C#NWJ7QHw0j&xwHw&B!1=oK { const client = useApolloClient(); @@ -36,35 +37,29 @@ 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"); - // 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" - } - }); + // 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: [pageBlockData.content] + }; - resolve(); - }); + setBlock({ + ...pageBlockData, + content + }); + }); return React.lazy(() => Promise.all([savedElements, blockData]).then(() => { 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..c153bfe9638 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,19 @@ 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)}` - ); + history.push(`/page-builder/page-blocks`); // Let's wait a bit, because we are also redirecting the user. setTimeout(() => { - showSnackbar("Your page was published successfully!"); + showSnackbar(`Block ${block.name} saved successfully!`); }, 500); } }) ); - }, [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..946f6278e26 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 @@ -4,9 +4,11 @@ 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"; // 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 +26,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(); + // 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: 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,13 +45,16 @@ 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); 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..8634e617bb7 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,15 @@ import { UpdateDocumentActionArgsType } from "~/editor/recoil/actions"; -import { SaveBlockActionEvent } from "./saveBlock"; import { BlockAtomType } from "~/blockEditor/state"; import { BlockEventActionCallable } from "~/blockEditor/types"; +import { ToggleSaveBlockStateActionEvent } from "./saveBlock/event"; export const updateBlockAction: BlockEventActionCallable< UpdateDocumentActionArgsType -> = (state, _, args) => { +> = async (state, meta, args) => { console.log("updateBlockAction", state, args); + + meta.eventActionHandler.trigger(new ToggleSaveBlockStateActionEvent({ saving: true })); + return { state: { block: { @@ -14,11 +17,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/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/helpers.ts b/packages/app-page-builder/src/editor/helpers.ts index 95f17ff5616..f2d95ba6165 100644 --- a/packages/app-page-builder/src/editor/helpers.ts +++ b/packages/app-page-builder/src/editor/helpers.ts @@ -126,9 +126,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??? */ 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} From 018150fa21760df239cb3dac5d0db65a1374dc82 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Thu, 25 Aug 2022 17:29:34 +0300 Subject: [PATCH 18/56] feat: add icon and description fields to Block Categories (#2590) * feat: add icon and description fields to Block Categories * feat: move DelayedOnChange component to UI package * fix: add TODOs for future improvements --- .../src/definitions/blockCategoryEntity.ts | 6 + .../src/definitions/blockCategoryEntity.ts | 6 + .../__tests__/graphql/blockCategories.test.ts | 41 +++- .../graphql/blockCategoriesSecurity.test.ts | 34 ++- .../graphql/graphql/blockCategories.ts | 2 + .../lifecycleEvents.blockCategories.test.ts | 30 ++- .../lifecycleEvents.pageBlocks.test.ts | 4 +- .../__tests__/graphql/pageBlocks.test.ts | 16 +- .../graphql/pageBlocksSecurity.test.ts | 32 ++- .../src/graphql/crud/blockCategories.crud.ts | 8 +- .../graphql/graphql/blockCategories.gql.ts | 4 + .../api-page-builder/src/graphql/types.ts | 2 + packages/api-page-builder/src/types.ts | 2 + .../src/admin/plugins/icons/index.tsx | 49 ++++ .../src/admin/plugins/index.ts | 4 +- .../admin/utils/createBlockCategoryPlugin.tsx | 19 +- .../BlockCategories/BlockCategoriesForm.tsx | 24 +- .../views/BlockCategories/IconPicker.tsx | 231 ++++++++++++++++++ .../admin/views/BlockCategories/graphql.ts | 2 + .../admin/views/BlockCategories/validators.ts | 8 + .../src/editor/components/IconPicker.tsx | 2 +- .../elementSettings/action/ActionSettings.tsx | 2 +- .../elementSettings/link/HrefSettings.tsx | 2 +- .../elementSettings/save/SaveAction.tsx | 9 +- .../elements/media/iframe/IFrameSettings.tsx | 2 +- .../config/blockEditing/SearchBlocks.tsx | 25 +- .../blockEditing/SearchBlocksStyled.tsx | 20 +- packages/app-page-builder/src/types.ts | 2 + .../src}/DelayedOnChange/DelayedOnChange.ts | 0 .../src}/DelayedOnChange/index.ts | 0 .../DelayedOnChange/withDelayedOnChange.tsx | 0 31 files changed, 531 insertions(+), 57 deletions(-) create mode 100644 packages/app-page-builder/src/admin/plugins/icons/index.tsx create mode 100644 packages/app-page-builder/src/admin/views/BlockCategories/IconPicker.tsx rename packages/{app-page-builder/src/editor/components => ui/src}/DelayedOnChange/DelayedOnChange.ts (100%) rename packages/{app-page-builder/src/editor/components => ui/src}/DelayedOnChange/index.ts (100%) rename packages/{app-page-builder/src/editor/components => ui/src}/DelayedOnChange/withDelayedOnChange.tsx (100%) 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 index 4f130991fda..e1a903423ac 100644 --- a/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts +++ b/packages/api-page-builder-so-ddb-es/src/definitions/blockCategoryEntity.ts @@ -28,6 +28,12 @@ export const createBlockCategoryEntity = (params: Params): Entity => { slug: { type: "string" }, + icon: { + type: "string" + }, + description: { + type: "string" + }, createdOn: { type: "string" }, diff --git a/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts b/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts index 4f130991fda..e1a903423ac 100644 --- a/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts +++ b/packages/api-page-builder-so-ddb/src/definitions/blockCategoryEntity.ts @@ -28,6 +28,12 @@ export const createBlockCategoryEntity = (params: Params): Entity => { slug: { type: "string" }, + icon: { + type: "string" + }, + description: { + type: "string" + }, createdOn: { type: "string" }, diff --git a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts index 50426bf5565..3117210c6db 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategories.test.ts @@ -23,7 +23,9 @@ describe("Block Categories CRUD Test", () => { const prefix = prefixes[i]; let data = { slug: `${prefix}slug`, - name: `${prefix}name` + name: `${prefix}name`, + icon: `${prefix}icon`, + description: `${prefix}description` }; let [response] = await createBlockCategory({ data }); @@ -60,7 +62,9 @@ describe("Block Categories CRUD Test", () => { data = { slug: data.slug, // Slug cannot be changed. - name: data.name + "-UPDATED" + name: data.name + "-UPDATED", + icon: data.icon + "-UPDATED", + description: data.description + "-UPDATED" }; [response] = await updateBlockCategory({ slug: data.slug, data }); @@ -90,18 +94,24 @@ describe("Block Categories CRUD Test", () => { { 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 } @@ -117,7 +127,9 @@ describe("Block Categories CRUD Test", () => { const prefix = prefixes[i]; const data = { slug: `${prefix}slug`, - name: `${prefix}name-UPDATED` + name: `${prefix}name-UPDATED`, + icon: `${prefix}icon-UPDATED`, + description: `${prefix}description-UPDATED` }; const [response] = await deleteBlockCategory({ slug: data.slug }); @@ -155,7 +167,9 @@ describe("Block Categories CRUD Test", () => { const [emptySlugErrorResponse] = await createBlockCategory({ data: { slug: ``, - name: `empty-slug-category` + name: `empty-slug-category-name`, + icon: `empty-slug-category-icon`, + description: `empty-slug-category-description` } }); @@ -187,7 +201,9 @@ describe("Block Categories CRUD Test", () => { const [invalidSlugErrorResponse] = await createBlockCategory({ data: { slug: `invalid--slug--category`, - name: `invalid--slug--category` + name: `invalid--slug--category--name`, + icon: `invalid--slug--category--icon`, + description: `invalid--slug--category--description` } }); @@ -242,7 +258,12 @@ describe("Block Categories CRUD Test", () => { test("cannot update a block category by empty slug", async () => { const [errorResponse] = await updateBlockCategory({ slug: ``, - data: { slug: ``, name: `empty-slug-category` } + data: { + slug: ``, + name: `empty-slug-category-name`, + icon: `empty-slug-category-icon`, + description: `empty-slug-category-description` + } }); const error: ErrorOptions = { @@ -267,7 +288,9 @@ describe("Block Categories CRUD Test", () => { await createBlockCategory({ data: { slug: `delete-block-cat`, - name: `name` + name: `name`, + icon: `icon`, + description: `description` } }); @@ -389,7 +412,9 @@ describe("Block Categories CRUD Test", () => { data: { createdBy: defaultIdentity, slug: `delete-block-cat`, - name: `name` + 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 index 5c5d03cfa01..4df9f1fb8b2 100644 --- a/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts +++ b/packages/api-page-builder/__tests__/graphql/blockCategoriesSecurity.test.ts @@ -4,6 +4,8 @@ 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 => ({ @@ -102,25 +104,33 @@ describe("Block Categories Security Test", () => { createdBy: identityA, createdOn: /^20/, slug: "list-block-categories-one-slug", - name: "list-block-categories-one-name" + 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" + 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" + 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" + name: "list-block-categories-four-name", + icon: "list-block-categories-four-icon", + description: "list-block-categories-four-description" } ], error: null @@ -145,13 +155,17 @@ describe("Block Categories Security Test", () => { createdBy: identityA, createdOn: /^20/, slug: "list-block-categories-one-slug", - name: "list-block-categories-one-name" + 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" + name: "list-block-categories-two-name", + icon: "list-block-categories-two-icon", + description: "list-block-categories-two-description" } ], error: null @@ -175,13 +189,17 @@ describe("Block Categories Security Test", () => { createdBy: identityB, createdOn: /^20/, slug: "list-block-categories-three-slug", - name: "list-block-categories-three-name" + 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" + name: "list-block-categories-four-name", + icon: "list-block-categories-four-icon", + description: "list-block-categories-four-description" } ], error: null diff --git a/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts b/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts index bc1dd66ee3b..6b8f30ff3d2 100644 --- a/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts +++ b/packages/api-page-builder/__tests__/graphql/graphql/blockCategories.ts @@ -2,6 +2,8 @@ export const DATA_FIELD = /* GraphQL */ ` { slug name + icon + description createdOn createdBy { id diff --git a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts index 602481136bb..739db476559 100644 --- a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.blockCategories.test.ts @@ -4,6 +4,8 @@ import { assignBlockCategoryLifecycleEvents, tracker } from "./mocks/lifecycleEv 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({ @@ -20,7 +22,9 @@ describe("Block Category Lifecycle Events", () => { const [response] = await createBlockCategory({ data: { slug, - name + name, + icon, + description } }); expect(response).toMatchObject({ @@ -29,7 +33,9 @@ describe("Block Category Lifecycle Events", () => { createBlockCategory: { data: { name, - slug + slug, + icon, + description }, error: null } @@ -49,7 +55,9 @@ describe("Block Category Lifecycle Events", () => { await createBlockCategory({ data: { slug, - name + name, + icon, + description } }); @@ -59,7 +67,9 @@ describe("Block Category Lifecycle Events", () => { slug: slug, data: { slug, - name: `${name} updated` + name: `${name} updated`, + icon: `${icon}-updated`, + description: `${name} Updated` } }); @@ -69,7 +79,9 @@ describe("Block Category Lifecycle Events", () => { updateBlockCategory: { data: { name: `${name} updated`, - slug + slug, + icon: `${icon}-updated`, + description: `${name} Updated` }, error: null } @@ -89,7 +101,9 @@ describe("Block Category Lifecycle Events", () => { await createBlockCategory({ data: { slug, - name + name, + icon, + description } }); @@ -104,7 +118,9 @@ describe("Block Category Lifecycle Events", () => { deleteBlockCategory: { data: { name, - slug + slug, + icon, + description }, error: null } diff --git a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts index 055b53b8ce2..e71609b163b 100644 --- a/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/lifecycleEvents.pageBlocks.test.ts @@ -40,7 +40,9 @@ describe("Page Block Lifecycle Events", () => { await createBlockCategory({ data: { slug: blockCategory, - name: `name` + name: `name`, + icon: `icon`, + description: `description` } }); // eslint-disable-next-line diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts index 7ef2f9e26d2..ef822e69efd 100644 --- a/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pageBlocks.test.ts @@ -22,7 +22,9 @@ describe("Page Blocks Test", () => { await createBlockCategory({ data: { slug: "block-category", - name: "block-category-name" + name: "block-category-name", + icon: "block-category-icon", + description: "block-category-description" } }); @@ -241,7 +243,9 @@ describe("Page Blocks Test", () => { await createBlockCategory({ data: { slug: "block-category", - name: "block-category-name" + name: "block-category-name", + icon: "block-category-icon", + description: "block-category-description" } }); @@ -331,14 +335,18 @@ describe("Page Blocks Test", () => { await createBlockCategory({ data: { slug: "block-category-one", - name: "block-category-one-name" + 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" + name: "block-category-two-name", + icon: "block-category-two-icon", + description: "block-category-two-description" } }); diff --git a/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts b/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts index 879a82940e7..4cc61ef9b01 100644 --- a/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts +++ b/packages/api-page-builder/__tests__/graphql/pageBlocksSecurity.test.ts @@ -54,7 +54,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -241,7 +243,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -290,7 +294,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); const data = new Mock(`page-block-create-${intAsString[i]}-`); @@ -314,7 +320,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -391,7 +399,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -426,7 +436,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -469,7 +481,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); @@ -505,7 +519,9 @@ describe("Page blocks Security Test", () => { await createBlockCategory({ data: { slug: `block-category`, - name: `block-category-name` + name: `block-category-name`, + icon: `block-category-icon`, + description: `block-category-description` } }); diff --git a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts index a29670db4cf..1eb955eb69b 100644 --- a/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/blockCategories.crud.ts @@ -30,11 +30,15 @@ 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") }) + 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") }) + 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"; diff --git a/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts index a59e7e58151..086a78d50e0 100644 --- a/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/blockCategories.gql.ts @@ -20,11 +20,15 @@ export const createBlockCategoryGraphQL = (): GraphQLSchemaPlugin => createdBy: PbCreatedBy name: String slug: String + icon: String + description: String } input PbBlockCategoryInput { name: String! slug: String! + icon: String! + description: String! } # Response types diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index d1cb7c4bea3..17e7bf32e7f 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -504,6 +504,8 @@ export interface SystemCrud { export interface PbBlockCategoryInput { name: string; slug: string; + icon: string; + description: string; } /** diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index beb41295437..3f28376e992 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -723,6 +723,8 @@ export interface PageBuilderStorageOperations { export interface BlockCategory { name: string; slug: string; + icon: string; + description: string; createdOn: string; createdBy: CreatedBy; tenant: string; diff --git a/packages/app-page-builder/src/admin/plugins/icons/index.tsx b/packages/app-page-builder/src/admin/plugins/icons/index.tsx new file mode 100644 index 00000000000..c61139e9d38 --- /dev/null +++ b/packages/app-page-builder/src/admin/plugins/icons/index.tsx @@ -0,0 +1,49 @@ +// 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"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { far } from "@fortawesome/free-regular-svg-icons"; +import { PbIcon, PbIconsPlugin } from "~/types"; +import { IconPrefix } from "@fortawesome/fontawesome-common-types"; + +const createSvg = (icon: string[]): React.ReactElement => { + return ( + + + + ); +}; + +const icons: PbIcon[] = []; + +const plugin: PbIconsPlugin = { + name: "pb-icons-fontawesome", + type: "pb-icons", + init() { + // @ts-ignore + library.add(fab, fas, far); + const definitions = (library as any).definitions as unknown as Record; + /** + * Ignoring TS errors. We know what we coded is good, but cannot get it to work with typescript. + */ + // @ts-ignore + Object.keys(definitions).forEach((pack: IconPrefix) => { + const defs = definitions[pack]; + // @ts-ignore + Object.keys(defs).forEach((icon: IconName) => { + icons.push({ + id: [pack, icon], + name: icon, + // @ts-ignore + svg: createSvg(defs[icon]) + }); + }); + }); + }, + getIcons() { + return icons; + } +}; + +export default plugin; 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/utils/createBlockCategoryPlugin.tsx b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx index e155b100536..18ae8c4597a 100644 --- a/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx +++ b/packages/app-page-builder/src/admin/utils/createBlockCategoryPlugin.tsx @@ -1,15 +1,30 @@ 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: "", - icon: <> + description: element.description || "", + icon: }; plugins.register(plugin); diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx index 27facff39e1..aa5b44789aa 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -12,8 +12,9 @@ import { SimpleFormContent, SimpleFormHeader } from "@webiny/app-admin/components/SimpleForm"; +import IconPicker from "./IconPicker"; import { validation } from "@webiny/validation"; -import { blockCategorySlugValidator } from "./validators"; +import { blockCategorySlugValidator, blockCategoryDescriptionValidator } from "./validators"; import { GET_BLOCK_CATEGORY, CREATE_BLOCK_CATEGORY, @@ -116,7 +117,7 @@ const CategoriesForm: React.FC = ({ canCreate }) => { const onSubmit = useCallback( async formData => { const isUpdate = loadedBlockCategory.slug; - const data = pick(formData, ["slug", "name"]); + const data = pick(formData, ["slug", "name", "icon", "description"]); let response; if (isUpdate) { @@ -225,6 +226,25 @@ const CategoriesForm: React.FC = ({ canCreate }) => { + + + + + + + + + + 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 index a582fdfd7b2..9a5d511306b 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/graphql.ts @@ -4,6 +4,8 @@ import { PbBlockCategory, PbErrorResponse } from "~/types"; export const PAGE_BLOCK_CATEGORY_BASE_FIELDS = ` slug name + icon + description createdOn createdBy { id diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts index 465d0be33e3..23a1ee1f4bd 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts +++ b/packages/app-page-builder/src/admin/views/BlockCategories/validators.ts @@ -11,3 +11,11 @@ export const blockCategorySlugValidator = (value: string): boolean => { 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/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/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/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 e31076a3798..179cfcf9b3d 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,7 +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 } from "~/admin/views/PageBlocks/graphql"; +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"; @@ -135,7 +139,8 @@ const SaveAction: React.FC = ({ children }) => { id: element.source, data: pick(formData, ["content", "preview"]) } - : { data: pick(formData, ["name", "blockCategory", "preview", "content"]) } + : { data: pick(formData, ["name", "blockCategory", "preview", "content"]) }, + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }] }); const { error, data } = get(res, `pageBuilder.pageBlock`); diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx b/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx index 703a0c61b9c..0aa7758a9c6 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx @@ -4,7 +4,7 @@ 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"; 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 293c5bc8b8e..7ca271df520 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"; @@ -20,7 +21,14 @@ import createBlockPlugin from "~/admin/utils/createBlockPlugin"; import BlocksList from "./BlocksList"; 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,7 @@ 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 { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { blocksBrowserStateAtom } from "~/pageEditor/config/blockEditing/state"; const allBlockCategory: PbEditorBlockCategoryPlugin = { @@ -251,11 +259,14 @@ const SearchBar = () => { - + {allCategories.map(p => ( { setActiveCategory(p.categoryName); }} @@ -271,14 +282,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/types.ts b/packages/app-page-builder/src/types.ts index 08636e58400..186c43ae563 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -778,6 +778,8 @@ export interface PbMenu { export interface PbBlockCategory { name: string; slug: string; + icon: string; + description: string; createdOn: string; createdBy: PbIdentity; } 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 From 93eca36553ee1ea908ecdd0565936b98c9df30aa Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 12 Sep 2022 14:50:42 +0300 Subject: [PATCH 19/56] fix: remove unused icons plugin (#2597) --- .../src/plugins/pageBuilder/editorPlugins.ts | 4 -- .../src/editor/plugins/icons/index.tsx | 48 ------------------- .../src/plugins/pageBuilder/editorPlugins.ts | 4 -- 3 files changed, 56 deletions(-) delete mode 100644 packages/app-page-builder/src/editor/plugins/icons/index.tsx diff --git a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts index dd737b534a3..bd9141abc2c 100644 --- a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts +++ b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts @@ -19,8 +19,6 @@ import codesandbox from "@webiny/app-page-builder/editor/plugins/elements/code/c 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"; @@ -83,8 +81,6 @@ export default [ pagesList(), // grid presets ...gridPresets, - // Icons - icons, // Element groups basicGroup, formGroup, diff --git a/packages/app-page-builder/src/editor/plugins/icons/index.tsx b/packages/app-page-builder/src/editor/plugins/icons/index.tsx deleted file mode 100644 index 2f839052b33..00000000000 --- a/packages/app-page-builder/src/editor/plugins/icons/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; -import { IconName, library } from "@fortawesome/fontawesome-svg-core"; -import { fab } from "@fortawesome/free-brands-svg-icons"; -import { fas } from "@fortawesome/free-solid-svg-icons"; -import { far } from "@fortawesome/free-regular-svg-icons"; -import { PbIcon, PbIconsPlugin } from "~/types"; -import { IconPrefix } from "@fortawesome/fontawesome-common-types"; - -const createSvg = (icon: string[]): React.ReactElement => { - return ( - - - - ); -}; - -const icons: PbIcon[] = []; - -const plugin: PbIconsPlugin = { - name: "pb-icons-fontawesome", - type: "pb-icons", - init() { - // @ts-ignore - library.add(fab, fas, far); - const definitions = (library as any).definitions as unknown as Record; - /** - * Ignoring TS errors. We know what we coded is good, but cannot get it to work with typescript. - */ - // @ts-ignore - Object.keys(definitions).forEach((pack: IconPrefix) => { - const defs = definitions[pack]; - // @ts-ignore - Object.keys(defs).forEach((icon: IconName) => { - icons.push({ - id: [pack, icon], - name: icon, - // @ts-ignore - svg: createSvg(defs[icon]) - }); - }); - }); - }, - getIcons() { - return icons; - } -}; - -export default plugin; 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 d649935a443..36a3e384c19 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 @@ -19,8 +19,6 @@ import iframe from "@webiny/app-page-builder/editor/plugins/elements/media/ifram 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"; @@ -83,8 +81,6 @@ export default [ pagesList(), // grid presets ...gridPresets, - // Icons - icons, // Element groups basicGroup, formGroup, From a255d65431ef4a4ee0666a8704de8608af303fb4 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 12 Sep 2022 14:51:12 +0300 Subject: [PATCH 20/56] fix: fix Blocks list page refresh bug (#2596) --- .../src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx index 6c77c3320df..f8d53ed34e7 100644 --- a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx @@ -178,7 +178,8 @@ const BlocksByCategoriesDataList = ({ canCreate }: PageBuilderBlocksByCategories }), preview: {} } - } + }, + refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }] }); const { error, data } = get(res, `pageBuilder.pageBlock`); if (data) { From 440ec5bb7f35388921075b697d2f11039fbdf184 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 14 Sep 2022 23:08:18 +0300 Subject: [PATCH 21/56] feat: blocks as refs (#2618) * feat: blocks as refs starter * feat: complete block ref handling and unlink button * fix: fix lint issues with blockId * fix: fix tab selection bug, fix react error * fix: fix code review comment * fix: change ids handling for saving of blocks and elements (#2625) --- .../src/graphql/graphql/pages.gql.ts | 22 +++++ packages/api-page-builder/src/types.ts | 4 +- .../src/blockEditor/Editor.tsx | 4 +- .../eventActions/saveBlock/saveBlockAction.ts | 3 +- .../src/editor/components/DropZone.tsx | 19 ++-- .../src/editor/components/Element.tsx | 95 ++++++++++--------- .../src/editor/contexts/ElementProvider.tsx | 16 ++++ .../app-page-builder/src/editor/helpers.ts | 22 ++++- .../editor/hooks/useCurrentBlockElement.tsx | 70 ++++++++++++++ .../src/editor/hooks/useCurrentElement.tsx | 16 ++++ .../app-page-builder/src/editor/index.tsx | 2 + .../elementSettings/save/SaveAction.tsx | 21 +--- .../src/pageEditor/Editor.tsx | 21 +++- .../pageEditor/config/BlockElementPlugin.tsx | 75 +++++++++++++++ .../config/BlockElementSidebarPlugin.tsx | 59 ++++++++++++ .../pageEditor/config/PageEditorConfig.tsx | 4 + .../config/blockEditing/SearchBlocks.tsx | 7 +- .../saveRevision/saveRevisionAction.ts | 20 +++- .../src/pageEditor/config/helpers.ts | 24 +++++ .../elementSettings/UnlinkBlockAction.ts | 28 ++++++ .../src/render/components/ElementRoot.tsx | 12 ++- packages/app-page-builder/src/render/index.ts | 1 + 22 files changed, 456 insertions(+), 89 deletions(-) create mode 100644 packages/app-page-builder/src/editor/contexts/ElementProvider.tsx create mode 100644 packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx create mode 100644 packages/app-page-builder/src/editor/hooks/useCurrentElement.tsx create mode 100644 packages/app-page-builder/src/pageEditor/config/BlockElementPlugin.tsx create mode 100644 packages/app-page-builder/src/pageEditor/config/BlockElementSidebarPlugin.tsx create mode 100644 packages/app-page-builder/src/pageEditor/config/helpers.ts create mode 100644 packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts create mode 100644 packages/app-page-builder/src/render/index.ts 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..dfb989c5d3e 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,28 @@ 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; + } + + const blocks = []; + for (const block of page.content?.elements) { + const blockId = block.data?.blockId; + if (blockId) { + const blockData = await context.pageBuilder.getPageBlock(blockId); + blocks.push({ + ...block, + data: { blockId, ...blockData?.content?.data }, + elements: blockData?.content?.elements || [] + }); + } else { + blocks.push(block); + } + } + + return { ...page.content, elements: blocks }; } }, PbPageListItem: { diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 3f28376e992..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; @@ -818,7 +818,7 @@ export interface PageBlock { id: string; name: string; blockCategory: string; - content: File; + content: any; preview: File; createdOn: string; createdBy: CreatedBy; diff --git a/packages/app-page-builder/src/blockEditor/Editor.tsx b/packages/app-page-builder/src/blockEditor/Editor.tsx index bf22c50e253..910d3a7b53a 100644 --- a/packages/app-page-builder/src/blockEditor/Editor.tsx +++ b/packages/app-page-builder/src/blockEditor/Editor.tsx @@ -14,7 +14,7 @@ import createElementPlugin from "~/admin/utils/createElementPlugin"; 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"; export const BlockEditor: React.FC = () => { @@ -52,7 +52,7 @@ export const BlockEditor: React.FC = () => { // 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: [pageBlockData.content] + elements: [addElementId(pageBlockData.content)] }; setBlock({ 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 946f6278e26..6d1e8e7f944 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 @@ -6,6 +6,7 @@ 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"; // TODO: add more properties here type BlockType = Pick; @@ -35,7 +36,7 @@ export const saveBlockAction: BlockEventActionCallable 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: element.elements[0] + content: removeElementId(element.elements[0]) }; if (debouncedSave) { 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/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/contexts/ElementProvider.tsx b/packages/app-page-builder/src/editor/contexts/ElementProvider.tsx new file mode 100644 index 00000000000..a0db8a695bb --- /dev/null +++ b/packages/app-page-builder/src/editor/contexts/ElementProvider.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { PbEditorElement } from "~/types"; + +export interface ElementContext { + element: PbEditorElement; +} + +export const ElementContext = React.createContext(undefined); + +export interface ElementProviderProps { + element: PbEditorElement; +} + +export const ElementProvider: React.FC = ({ element, children }) => { + return {children}; +}; diff --git a/packages/app-page-builder/src/editor/helpers.ts b/packages/app-page-builder/src/editor/helpers.ts index f2d95ba6165..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"; @@ -146,6 +147,25 @@ export const addElementId = (target: Omit): PbEditorEleme } 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..2a7aef85808 --- /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. + */ +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/index.tsx b/packages/app-page-builder/src/editor/index.tsx index ed58fd009b0..6a0e83c91a7 100644 --- a/packages/app-page-builder/src/editor/index.tsx +++ b/packages/app-page-builder/src/editor/index.tsx @@ -14,3 +14,5 @@ export * from "./components/Editor/EditorContent"; export { EditorProvider } from "./contexts/EditorProvider"; export { EditorSidebarTab, EditorSidebarTabProps } from "./components/Editor/EditorSidebar"; export { ElementSettingsRenderer } from "./plugins/elementSettings/advanced/ElementSettings"; +export * from "../render/components/ElementRoot"; +export { default as DropZone } from "../editor/components/DropZone"; 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 179cfcf9b3d..8fde8359483 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 @@ -31,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; @@ -94,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); diff --git a/packages/app-page-builder/src/pageEditor/Editor.tsx b/packages/app-page-builder/src/pageEditor/Editor.tsx index 50bfe29e88d..0ef7e24020d 100644 --- a/packages/app-page-builder/src/pageEditor/Editor.tsx +++ b/packages/app-page-builder/src/pageEditor/Editor.tsx @@ -4,7 +4,7 @@ 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, @@ -24,7 +24,7 @@ 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, PbPageBlock, PbBlockCategory } 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"; @@ -49,6 +49,16 @@ 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 = () => { const client = useApolloClient(); const { history, params } = useRouter(); @@ -122,9 +132,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") { 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/PageEditorConfig.tsx b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx index 3b7ec7da585..e70ddde6af0 100644 --- a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx +++ b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx @@ -2,6 +2,8 @@ import React from "react"; import { EventActionPlugins, EventActionHandlerPlugin } from "./eventActions"; import { EditorBarPlugins } from "./editorBar"; import { BlockEditingPlugin } from "./blockEditing"; +import { BlockElementPlugin } from "./BlockElementPlugin"; +import { BlockElementSidebarPlugin } from "./BlockElementSidebarPlugin"; export const PageEditorConfig = React.memo(() => { return ( @@ -10,6 +12,8 @@ export const PageEditorConfig = React.memo(() => { + + ); }); 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 7ca271df520..649dadeee08 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx @@ -36,6 +36,7 @@ import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { useKeyHandler } from "~/editor/hooks/useKeyHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { createBlockElements } from "~/editor/helpers"; +import { createBlockReference } from "~/pageEditor/config/helpers"; import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { blocksBrowserStateAtom } from "~/pageEditor/config/blockEditing/state"; @@ -111,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({ diff --git a/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts index 4910acc7eef..ace15d8807b 100644 --- a/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts +++ b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts @@ -5,6 +5,7 @@ import { SaveRevisionActionArgsType } from "./types"; import { ToggleSaveRevisionStateActionEvent } from "./event"; import { PageAtomType } from "~/pageEditor/state"; import { PageEventActionCallable } from "~/pageEditor/types"; +import { PbEditorElement } from "~/types"; interface PageRevisionType extends Pick { category: string; @@ -26,6 +27,21 @@ const triggerOnFinish = (args?: SaveRevisionActionArgsType): void => { // TODO @ts-refactor not worth it let debouncedSave: any = null; +const removeElementsFromReferences = (content: any) => { + const elements = content.elements.map((element: PbEditorElement) => { + if (element.data.blockId) { + return { + ...element, + elements: [] + }; + } else { + return element; + } + }); + + return { ...content, elements }; +}; + export const saveRevisionAction: PageEventActionCallable = async ( state, meta, @@ -37,12 +53,14 @@ export const saveRevisionAction: PageEventActionCallable { + 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 } + }; +}; 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..48318d9adc4 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts @@ -0,0 +1,28 @@ +import React, { useCallback } from "react"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; + +interface UnlinkBlockActionPropsType { + children: React.ReactElement; +} +const UnlinkBlockAction: React.FC = ({ children }) => { + const [element] = useActiveElement(); + const updateElement = useUpdateElement(); + + const onClick = useCallback((): void => { + if (element) { + // we need to drop blockId property wheen unlinking, so it is separated from all other element data + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { blockId, ...newData } = element.data; + + updateElement({ + ...element, + data: newData + }); + } + }, [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/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"; From 6939d35a7ee4a383d3cb1cf6a77b4146420d5bc2 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 28 Sep 2022 13:22:41 +0300 Subject: [PATCH 22/56] feat: update generated pages on block update (#2598) * feat: update generated pages on block update * fix: fix code review comments --- .../hooks/afterPageBlockUpdate.ts | 15 ++ .../src/prerendering/hooks/index.ts | 2 + .../render/plugins/elements/block/Block.tsx | 135 ++++++++++-------- 3 files changed, 93 insertions(+), 59 deletions(-) create mode 100644 packages/api-page-builder/src/prerendering/hooks/afterPageBlockUpdate.ts 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/app-page-builder/src/render/plugins/elements/block/Block.tsx b/packages/app-page-builder/src/render/plugins/elements/block/Block.tsx index f08ef71c3c4..19007252540 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; @@ -16,66 +30,69 @@ const Block: React.FC = ({ element }) => { 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 => ( + + ))} +
-
- ); - }} -
-
+ ); + }} +
+
+ ); }; From d073a0483f3eb08192f8ab0fe34701a5c848223d Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 28 Sep 2022 13:22:54 +0300 Subject: [PATCH 23/56] feat: remove "save" button for ref blocks (#2634) --- .../Sidebar/ElementSettingsTabContent.tsx | 12 +++++-- .../app-page-builder/src/editor/index.tsx | 1 + .../ElementSettingsTabContentPlugin.tsx | 33 +++++++++++++++++++ .../pageEditor/config/PageEditorConfig.tsx | 2 ++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx 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/index.tsx b/packages/app-page-builder/src/editor/index.tsx index 6a0e83c91a7..92d1170849c 100644 --- a/packages/app-page-builder/src/editor/index.tsx +++ b/packages/app-page-builder/src/editor/index.tsx @@ -13,6 +13,7 @@ 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"; 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..bf451982330 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx @@ -0,0 +1,33 @@ +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"; + +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} +
+ ); + }; + } +); diff --git a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx index e70ddde6af0..9ca1d646c53 100644 --- a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx +++ b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx @@ -4,6 +4,7 @@ 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 ( @@ -14,6 +15,7 @@ export const PageEditorConfig = React.memo(() => { + ); }); From 9e5bb906fc379d61a0dddf75c6809159045f6baa Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 28 Sep 2022 14:09:07 +0300 Subject: [PATCH 24/56] feat: make getElementTree composable (#2633) * feat: make getElementTree composable * fix: improve TypeScript types definition * fix: fix Typescript types --- .../eventActions/saveBlock/saveBlockAction.ts | 3 +- .../contexts/EventActionHandlerProvider.tsx | 71 +++++++++++-------- .../elementSettings/save/SaveAction.tsx | 2 +- .../eventActions/EventActionHandlerPlugin.tsx | 35 +++++++++ packages/app-page-builder/src/types.ts | 9 ++- 5 files changed, 87 insertions(+), 33 deletions(-) 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 6d1e8e7f944..535b50ecf80 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 @@ -7,6 +7,7 @@ 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 } from "~/types"; // TODO: add more properties here type BlockType = Pick; @@ -27,7 +28,7 @@ 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(); + 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); diff --git a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx index 82604ed3831..938eb3fb818 100644 --- a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx @@ -39,9 +39,10 @@ import { PbEditorElement, EventActionHandler, EventActionHandlerTarget, - EventActionHandlerCallableState + EventActionHandlerCallableState, + GetElementTreeProps } from "~/types"; -import { composeSync, SyncProcessor } from "@webiny/utils/compose"; +import { composeAsync, composeSync, AsyncProcessor, SyncProcessor } from "@webiny/utils/compose"; import { UpdateElementTreeActionEvent } from "~/editor/recoil/actions"; type ListType = 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>; } @@ -170,33 +173,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); 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 8fde8359483..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 @@ -78,7 +78,7 @@ const SaveAction: React.FC = ({ children }) => { const client = useApolloClient(); const onSubmit = async (formData: PbDocumentElement) => { - const pbElement = (await getElementTree(element)) as PbElement; + const pbElement = (await getElementTree({ element })) as PbElement; formData.content = pluginOnSave(removeElementId(pbElement)); const meta = await getDataURLImageDimensions(formData.preview); 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/types.ts b/packages/app-page-builder/src/types.ts index 186c43ae563..39145bdcaba 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -688,11 +688,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 +714,7 @@ export interface EventActionHandler { endBatch: () => void; enableHistory: () => void; disableHistory: () => void; - getElementTree: (element?: PbEditorElement) => Promise; + getElementTree: (props: GetElementTreeProps) => Promise; } export interface EventActionHandlerTarget { From d6c59160c9640fd7721d68a966a86f36fa5fd04f Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 5 Oct 2022 17:08:38 +0300 Subject: [PATCH 25/56] test: add API test for resolved ref blocks, remove not required ref block manipulation (#2645) --- .../__tests__/graphql/pages.test.ts | 115 +++++++++++++++++- .../saveRevision/saveRevisionAction.ts | 20 +-- 2 files changed, 114 insertions(+), 21 deletions(-) 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/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts index ace15d8807b..4910acc7eef 100644 --- a/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts +++ b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts @@ -5,7 +5,6 @@ import { SaveRevisionActionArgsType } from "./types"; import { ToggleSaveRevisionStateActionEvent } from "./event"; import { PageAtomType } from "~/pageEditor/state"; import { PageEventActionCallable } from "~/pageEditor/types"; -import { PbEditorElement } from "~/types"; interface PageRevisionType extends Pick { category: string; @@ -27,21 +26,6 @@ const triggerOnFinish = (args?: SaveRevisionActionArgsType): void => { // TODO @ts-refactor not worth it let debouncedSave: any = null; -const removeElementsFromReferences = (content: any) => { - const elements = content.elements.map((element: PbEditorElement) => { - if (element.data.blockId) { - return { - ...element, - elements: [] - }; - } else { - return element; - } - }); - - return { ...content, elements }; -}; - export const saveRevisionAction: PageEventActionCallable = async ( state, meta, @@ -53,14 +37,12 @@ export const saveRevisionAction: PageEventActionCallable Date: Wed, 5 Oct 2022 17:09:03 +0300 Subject: [PATCH 26/56] fix: fix not refreshed Navigator tree for editors (#2644) --- .../src/editor/contexts/EventActionHandlerProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx index 938eb3fb818..a27f04a5ab9 100644 --- a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx @@ -136,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()); From ea4298c1cdb4e94769f2347220d00d3a5edd1c1f Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 17 Oct 2022 15:39:54 +0300 Subject: [PATCH 27/56] feat: page block variables feature (#2672) * feat: page block variables feature * feat: return empty grid block * fix: remove timeout for showSnackbar * fix: fix code style issue * fix: fix code style for a removeVarRef function * fix: extract API resolver code in separate file * fix: change varRef to id --- .../src/plugins/pageBuilder/editorPlugins.ts | 3 + .../src/graphql/graphql/pages.gql.ts | 18 +- .../graphql/utils/resolvePageBlocks.ts | 43 +++++ .../elementSettingsTab/ElementNotLinked.tsx | 47 +++++ .../elementSettingsTab/VariableSettings.tsx | 109 ++++++++++++ .../elementSettingsTab/VariablesList.tsx | 115 ++++++++++++ .../elementSettingsTab/variablesListHooks.ts | 167 ++++++++++++++++++ .../blockEditor/config/BlockEditorConfig.tsx | 4 +- .../config/BlockElementSidebar.tsx | 21 --- .../ElementSettingsTabContentPlugin.tsx | 31 ++++ .../SaveBlockButton/SaveBlockButton.tsx | 8 +- .../eventActions/saveBlock/saveBlockAction.ts | 40 ++++- .../elementSettings/CreateVariableAction.ts | 42 +++++ .../src/editor/components/Text/PbText.tsx | 4 +- .../editor/hooks/useCurrentBlockElement.tsx | 2 +- .../editor/hooks/useElementVariableValue.ts | 20 +++ .../src/editor/hooks/useParentBlock.ts | 6 + .../elementSettings/clone/CloneAction.ts | 14 +- .../elementSettings/delete/DeleteAction.ts | 26 ++- .../variable/VariableSettings.tsx | 69 ++++++++ .../ElementSettingsTabContentPlugin.tsx | 31 ++-- .../config/blockEditing/SearchBlocks.tsx | 2 +- .../src/pageEditor/config/helpers.ts | 24 --- .../src/pageEditor/helpers.ts | 53 ++++++ .../pageEditor/plugins/blocks/emptyBlock.tsx | 32 ++++ .../elementSettings/UnlinkBlockAction.ts | 22 ++- .../render/plugins/elements/block/Block.tsx | 1 + packages/app-page-builder/src/types.ts | 6 + 28 files changed, 863 insertions(+), 97 deletions(-) create mode 100644 packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts create mode 100644 packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx create mode 100644 packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx create mode 100644 packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx create mode 100644 packages/app-page-builder/src/blockEditor/components/elementSettingsTab/variablesListHooks.ts delete mode 100644 packages/app-page-builder/src/blockEditor/config/BlockElementSidebar.tsx create mode 100644 packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts create mode 100644 packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts create mode 100644 packages/app-page-builder/src/editor/hooks/useParentBlock.ts create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx delete mode 100644 packages/app-page-builder/src/pageEditor/config/helpers.ts create mode 100644 packages/app-page-builder/src/pageEditor/helpers.ts create mode 100644 packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx diff --git a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts index bd9141abc2c..c18a71c4816 100644 --- a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts +++ b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts @@ -27,6 +27,8 @@ import formGroup from "@webiny/app-page-builder/editor/plugins/elementGroups/for 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 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"; @@ -62,6 +64,7 @@ export default [ document(), grid(), block(), + emptyBlock, cell(), heading(), paragraph(), 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 dfb989c5d3e..ac34f40c366 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -8,6 +8,7 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; import { Page, PbContext, PageSecurityPermission } from "~/types"; import WebinyError from "@webiny/error"; import resolve from "./utils/resolve"; +import resolvePageBlocks from "./utils/resolvePageBlocks"; import { createPageSettingsGraphQL } from "./pages/pageSettings"; import { fetchEmbed, findProvider } from "./pages/oEmbed"; import lodashGet from "lodash/get"; @@ -283,22 +284,7 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { if (!page.content?.elements) { return page.content; } - - const blocks = []; - for (const block of page.content?.elements) { - const blockId = block.data?.blockId; - if (blockId) { - const blockData = await context.pageBuilder.getPageBlock(blockId); - blocks.push({ - ...block, - data: { blockId, ...blockData?.content?.data }, - elements: blockData?.content?.elements || [] - }); - } else { - blocks.push(block); - } - } - + const blocks = await resolvePageBlocks(page, context); return { ...page.content, elements: blocks }; } }, diff --git a/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts b/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts new file mode 100644 index 00000000000..f66eecaa2ca --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts @@ -0,0 +1,43 @@ +import { Page, PbContext } from "~/types"; + +const resolvePageBlocks = async (page: Page, context: PbContext) => { + const blocks = []; + + for (const pageBlock of page.content?.elements) { + const blockId = pageBlock.data?.blockId; + // If block has blockId, then it is reference block and we need to get elements for it + if (blockId) { + const blockData = await context.pageBuilder.getPageBlock(blockId); + // We check if block has variable values set on the page and use them in priority over ones, + // that are set in blockEditor + 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({ + ...pageBlock, + data: { + blockId, + ...blockData?.content?.data, + variables + }, + elements: blockData?.content?.elements || [] + }); + } else { + blocks.push(pageBlock); + } + } + + return blocks; +}; + +export default resolvePageBlocks; 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..5a034afbb6f --- /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"; + +const ElementNotLinkedWrapper = 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/VariableSettings.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx new file mode 100644 index 00000000000..02a6ce65f0e --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useMemo } from "react"; +import styled from "@emotion/styled"; +import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; +import { PbEditorElement, PbBlockVariable } from "~/types"; +import { Form } from "@webiny/form"; +import { validation } from "@webiny/validation"; +import { Input } from "@webiny/ui/Input"; +import { useCurrentBlockElement } from "~/editor/hooks/useCurrentBlockElement"; +import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; + +const FormWrapper = styled("div")({ + padding: "16px", + display: "grid", + rowGap: "16px" +}); + +const VariableSettings = ({ element }: { element: PbEditorElement }) => { + const { block } = useCurrentBlockElement(); + const updateElement = useUpdateElement(); + + const initialData = useMemo(() => { + const variable = block?.data?.variables?.find( + (variable: PbBlockVariable) => variable.id === element?.data?.variableId + ); + + return { label: variable?.label }; + }, [block, element]); + + const onSubmit = useCallback( + formData => { + if (block && block.id) { + const newVariables = block.data?.variables?.map((variable: PbBlockVariable) => { + if (variable?.id === element?.data?.variableId) { + return { + ...variable, + label: formData.label + }; + } else { + return variable; + } + }); + updateElement({ + ...block, + data: { + ...block.data, + variables: newVariables + } + }); + } + }, + [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 !== 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 + }); + } + }), + [block, element] + ); + + return ( +
+ {({ data, form, Bind }) => ( + + + + + { + form.submit(ev); + }} + > + Save + + Remove Variable + + )} +
+ ); +}; + +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..03dfc6e62d1 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { PbEditorElement, PbBlockVariable } 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 { ReactComponent as DragIndicatorIcon } from "~/editor/plugins/toolbar/navigator/assets/drag_indicator_24px.svg"; + +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)" + }, + + "&>svg": { + cursor: "move" + } +}); + +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 +}: { + variable: PbBlockVariable; + index: number; + move: (current: number, next: number) => 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} + + + + ); +}; + +const VariablesList = ({ element }: { element: PbEditorElement }) => { + const { move } = useMoveVariable(element); + + return ( + <> + + Block variables + + {element?.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..c1f506ccd70 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx @@ -0,0 +1,31 @@ +import React from "react"; +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"; + +export const ElementSettingsTabContentPlugin = createComponentPlugin( + SidebarActions, + SidebarActionsWrapper => { + return function SettingsTabContent({ children, ...props }) { + const [element] = useActiveElement(); + const canHaveVariable = element && element.type === "heading"; + 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/SaveBlockButton/SaveBlockButton.tsx b/packages/app-page-builder/src/blockEditor/config/editorBar/SaveBlockButton/SaveBlockButton.tsx index c153bfe9638..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 @@ -18,13 +18,9 @@ const DefaultSaveBlockButton: React.FC = () => { eventActionHandler.trigger( new SaveBlockActionEvent({ debounce: false, - onFinish() { + onFinish: () => { history.push(`/page-builder/page-blocks`); - - // Let's wait a bit, because we are also redirecting the user. - setTimeout(() => { - showSnackbar(`Block ${block.name} saved successfully!`); - }, 500); + showSnackbar(`Block "${block.name}" saved successfully!`); } }) ); 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 535b50ecf80..09143b8964b 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 @@ -7,7 +7,39 @@ 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 } from "~/types"; +import { PbElement, PbElementDataType, PbBlockVariable } from "~/types"; + +function findNestedObj( + entireObj: PbElement[], + keyToFind: string, + valToFind: string +): PbElementDataType | undefined { + let foundObj; + JSON.stringify(entireObj, (_, nestedValue) => { + if (nestedValue && nestedValue[keyToFind] === valToFind) { + foundObj = nestedValue; + } + return nestedValue; + }); + return foundObj; +} + +const syncBlockVariables = (block: PbElement) => { + const syncedVariables = block.data?.variables?.reduce(function ( + result: Array, + variable: PbBlockVariable + ) { + const dataObject = findNestedObj(block.elements, "variableId", variable.id); + + if (dataObject) { + result.push({ ...variable, value: dataObject?.text?.data?.text }); + } + return result; + }, + []); + + return { ...block, data: { ...block.data, variables: syncedVariables } }; +}; // TODO: add more properties here type BlockType = Pick; @@ -37,7 +69,7 @@ export const saveBlockAction: BlockEventActionCallable 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(element.elements[0]) + content: removeElementId(syncBlockVariables(element.elements[0])) }; if (debouncedSave) { @@ -63,7 +95,9 @@ export const saveBlockAction: BlockEventActionCallable 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/plugins/elementSettings/CreateVariableAction.ts b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts new file mode 100644 index 00000000000..b8de0ca37a5 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts @@ -0,0 +1,42 @@ +import React, { useCallback } from "react"; +import startCase from "lodash/startCase"; +import camelCase from "lodash/camelCase"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useCurrentBlockElement } from "~/editor/hooks/useCurrentBlockElement"; + +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 && !element.data?.variableId && block && block.id) { + updateElement({ + ...element, + data: { ...element.data, variableId: element.id } + }); + updateElement({ + ...block, + data: { + ...block.data, + variables: [ + ...(block.data?.variables || []), + { + id: element.id, + label: startCase(camelCase(element.type)) + } + ] + } + }); + } + }, [element, block, updateElement]); + + return React.cloneElement(children, { onClick }); +}; + +export default React.memo(CreateVariableAction); 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..78d75137ee4 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({ @@ -57,7 +59,7 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro return null; } - const textContent = get(element, `${DATA_NAMESPACE}.data.text`); + const textContent = variableValue || get(element, `${DATA_NAMESPACE}.data.text`); const tag = get(value, "tag"); const typography = get(value, "typography"); diff --git a/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx b/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx index 2a7aef85808..0860eb808a9 100644 --- a/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx +++ b/packages/app-page-builder/src/editor/hooks/useCurrentBlockElement.tsx @@ -11,7 +11,7 @@ export interface UseCurrentBlock { /** * This selector will traverse the "elements" atom, going up the tree, until it finds the root block element. */ -const blockByElementSelector = selectorFamily({ +export const blockByElementSelector = selectorFamily({ key: "blockByElementSelector", get: id => { return ({ get }) => { 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..18fbb180ebc --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { PbBlockVariable, PbEditorElement } from "~/types"; +import { useParentBlock } from "~/editor/hooks/useParentBlock"; + +export function useElementVariableValue(element: PbEditorElement | null) { + const block = useParentBlock(element?.id); + + const variableValue = useMemo(() => { + if (element?.data?.variableId) { + const variable = block?.data?.variables?.find( + (variable: PbBlockVariable) => variable.id === element?.data?.variableId + ); + return variable?.value; + } else { + return null; + } + }, [block, 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/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/delete/DeleteAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts index b4e2079f46d..88e3d7d82e8 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 !== 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/variable/VariableSettings.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx new file mode 100644 index 00000000000..c177a10fb42 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from "react"; +import styled from "@emotion/styled"; +import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import { useActiveElement } from "~/editor/hooks/useActiveElement"; +import { Input } from "@webiny/ui/Input"; +import { PbBlockVariable } from "~/types"; + +const VariableSettingsWrapper = styled("div")({ + padding: "16px", + display: "grid", + rowGap: "16px" +}); + +const VariableSettings: React.FC = () => { + const [element] = useActiveElement(); + const updateElement = useUpdateElement(); + + const onChange = useCallback( + (value: string, variableId: string) => { + if (element) { + const newVariables = element?.data?.variables?.map((variable: PbBlockVariable) => { + if (variable?.id === variableId) { + return { + ...variable, + value + }; + } else { + return variable; + } + }); + updateElement( + { + ...element, + data: { + ...element.data, + variables: newVariables + } + }, + { + history: false + } + ); + } + }, + [element, updateElement] + ); + + const onBlur = useCallback(() => { + if (element) { + updateElement(element); + } + }, [element, updateElement]); + + return ( + + {element?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( + onChange(value, variable.id)} + onBlur={onBlur} + /> + ))} + + ); +}; + +export default VariableSettings; diff --git a/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx b/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx index bf451982330..a931882106b 100644 --- a/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx +++ b/packages/app-page-builder/src/pageEditor/config/ElementSettingsTabContentPlugin.tsx @@ -3,6 +3,7 @@ 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, @@ -14,19 +15,23 @@ export const ElementSettingsTabContentPlugin = createComponentPlugin( 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 + ? 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/blockEditing/SearchBlocks.tsx b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx index 649dadeee08..cd6aa447d0a 100644 --- a/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx +++ b/packages/app-page-builder/src/pageEditor/config/blockEditing/SearchBlocks.tsx @@ -36,7 +36,7 @@ import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { useKeyHandler } from "~/editor/hooks/useKeyHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; import { createBlockElements } from "~/editor/helpers"; -import { createBlockReference } from "~/pageEditor/config/helpers"; +import { createBlockReference } from "~/pageEditor/helpers"; import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; import { blocksBrowserStateAtom } from "~/pageEditor/config/blockEditing/state"; diff --git a/packages/app-page-builder/src/pageEditor/config/helpers.ts b/packages/app-page-builder/src/pageEditor/config/helpers.ts deleted file mode 100644 index 98f14df07ae..00000000000 --- a/packages/app-page-builder/src/pageEditor/config/helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import invariant from "invariant"; -import { plugins } from "@webiny/plugins"; -import { getNanoid, addElementId } from "~/editor/helpers"; -import { PbEditorBlockPlugin, PbEditorElement } 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 } - }; -}; 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..d7750fd688d --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/helpers.ts @@ -0,0 +1,53 @@ +import invariant from "invariant"; +import { plugins } from "@webiny/plugins"; +import { getNanoid, addElementId } from "~/editor/helpers"; +import { PbEditorBlockPlugin, PbEditorElement, PbElement, PbBlockVariable } 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 variableValue = variables.find( + (variable: PbBlockVariable) => variable.id === el.data.variableId + )?.value; + + if (el.data?.text?.data?.text && variableValue) { + el.data.text.data.text = variableValue; + } + // @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/pageEditor/plugins/blocks/emptyBlock.tsx b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx new file mode 100644 index 00000000000..e558cd84214 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { createElement } from "~/editor/helpers"; +import { PbEditorBlockPlugin, PbEditorElement } from "~/types"; +import preview from "~/admin/views/PageBlocks/assets/preview.png"; + +const width = 1000; +const height = 117; +const aspectRatio = width / height; + +const plugin: PbEditorBlockPlugin = { + name: "pb-editor-empty-block", + type: "pb-editor-block", + blockCategory: "general", + title: "Empty block", + create(): PbEditorElement { + return createElement("block", { + elements: [createElement("grid")] + }); + }, + image: { + meta: { + width, + height, + aspectRatio + } + }, + tags: [], + preview(): React.ReactElement { + return {"Empty; + } +}; +export default plugin; diff --git a/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts b/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts index 48318d9adc4..d10ba8d9dbd 100644 --- a/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts +++ b/packages/app-page-builder/src/pageEditor/plugins/elementSettings/UnlinkBlockAction.ts @@ -1,24 +1,32 @@ 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((): void => { + const onClick = useCallback(async (): Promise => { if (element) { - // we need to drop blockId property wheen unlinking, so it is separated from all other element data + // 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, ...newData } = element.data; + 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({ - ...element, - data: newData - }); + updateElement(elementWithoutVariableIds); } }, [element, updateElement]); 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 19007252540..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 @@ -29,6 +29,7 @@ const Block: React.FC = ({ element }) => { const { responsiveDisplayMode: { displayMode } } = React.useContext(PageBuilderContext); + return ( <> diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index 39145bdcaba..7abce72117b 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -206,6 +206,12 @@ export interface PbElement { text?: string; } +export interface PbBlockVariable { + id: string; + label: string; + value?: string; +} + /** * Determine types for elements */ From 9897678dbd1db86e77cea12d830ca56e5792d41d Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Tue, 25 Oct 2022 15:34:46 +0300 Subject: [PATCH 28/56] fix: fix not refreshed list of blocks inside page builder (#2689) --- .../admin/views/PageBlocks/BlocksByCategoriesDataList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx index f8d53ed34e7..5b693d1d340 100644 --- a/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/PageBlocks/BlocksByCategoriesDataList.tsx @@ -29,7 +29,7 @@ import { ReactComponent as FilterIcon } from "@webiny/app-admin/assets/icons/fil import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; import { PbBlockCategory, PbPageBlock } from "~/types"; -import { LIST_PAGE_BLOCKS_AND_CATEGORIES, CREATE_PAGE_BLOCK } from "./graphql"; +import { LIST_PAGE_BLOCKS_AND_CATEGORIES, LIST_PAGE_BLOCKS, CREATE_PAGE_BLOCK } from "./graphql"; import { addElementId } from "~/editor/helpers"; @@ -179,7 +179,10 @@ const BlocksByCategoriesDataList = ({ canCreate }: PageBuilderBlocksByCategories preview: {} } }, - refetchQueries: [{ query: LIST_PAGE_BLOCKS_AND_CATEGORIES }] + refetchQueries: [ + { query: LIST_PAGE_BLOCKS_AND_CATEGORIES }, + { query: LIST_PAGE_BLOCKS } + ] }); const { error, data } = get(res, `pageBuilder.pageBlock`); if (data) { From 5aba0b8a8c4cd8ab8cfe48ed197c36b88e63753c Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Tue, 1 Nov 2022 17:21:40 +0200 Subject: [PATCH 29/56] feat: refactored Block Variables with plugins usage (#2734) * feat: refactored Block Variables with plugins usage * fix: add TODO comment --- .../src/blockEditor/Editor.tsx | 3 + .../elementSettingsTab/ElementNotLinked.tsx | 6 +- .../elementSettingsTab/TextInput.tsx | 33 ++++++ .../elementSettingsTab/VariableSettings.tsx | 99 +++++++++------- .../elementSettingsTab/VariablesList.tsx | 107 ++++++++++++++++-- .../ElementSettingsTabContentPlugin.tsx | 12 +- .../eventActions/saveBlock/saveBlockAction.ts | 47 +++++--- .../config/eventActions/updateBlockAction.ts | 5 +- .../elementSettings/CreateVariableAction.ts | 22 ++-- .../plugins/elementVariables/button/index.ts | 36 ++++++ .../plugins/elementVariables/heading/index.ts | 20 ++++ .../plugins/elementVariables/image/index.ts | 20 ++++ .../elementVariables/imagesList/index.ts | 20 ++++ .../plugins/elementVariables/index.ts | 9 ++ .../plugins/elementVariables/list/index.ts | 20 ++++ .../elementVariables/paragraph/index.ts | 20 ++++ .../plugins/elementVariables/quote/index.ts | 20 ++++ .../editor/components/MediumEditor/index.ts | 54 ++++++--- .../editor/hooks/useElementVariableValue.ts | 29 ++++- .../app-page-builder/src/editor/index.tsx | 2 + .../elementSettings/delete/DeleteAction.ts | 2 +- .../variable/MultipleImageVariableInput.tsx | 90 +++++++++++++++ .../variable/QuoteVariableInput.tsx | 39 +++++++ .../variable/RichVariableInput.tsx | 53 +++++++++ .../variable/SingleImageVariableInput.tsx | 26 +++++ .../variable/TextVariableInput.tsx | 31 +++++ .../variable/VariableSettings.tsx | 80 +++++-------- .../plugins/elementVariables/button/index.tsx | 43 +++++++ .../elementVariables/heading/index.tsx | 26 +++++ .../plugins/elementVariables/image/index.tsx | 26 +++++ .../elementVariables/images-list/index.tsx | 26 +++++ .../editor/plugins/elementVariables/index.ts | 9 ++ .../plugins/elementVariables/list/index.tsx | 26 +++++ .../elementVariables/paragraph/index.tsx | 26 +++++ .../plugins/elementVariables/quote/index.tsx | 26 +++++ .../elements/button/ButtonContainer.tsx | 3 + .../elements/button/SimpleEditableText.tsx | 4 +- .../plugins/elements/image/ImageContainer.tsx | 8 +- .../elements/imagesList/ImagesList.tsx | 20 ++-- .../plugins/elements/imagesList/index.tsx | 3 +- .../app-page-builder/src/hooks/useVariable.ts | 50 ++++++++ .../src/pageEditor/Editor.tsx | 3 + .../pageEditor/config/PageEditorConfig.tsx | 3 + .../config/elements/ImageContainerPlugin.tsx | 29 +++++ .../config/elements/ImagesListPlugin.tsx | 26 +++++ .../src/pageEditor/config/elements/index.ts | 2 + .../src/pageEditor/helpers.ts | 28 +++-- .../pageEditor/plugins/blocks/emptyBlock.tsx | 2 +- packages/app-page-builder/src/types.ts | 20 +++- packages/ui/src/Button/Button.tsx | 2 +- 50 files changed, 1135 insertions(+), 181 deletions(-) create mode 100644 packages/app-page-builder/src/blockEditor/components/elementSettingsTab/TextInput.tsx create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/button/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/heading/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/image/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/imagesList/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/list/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/paragraph/index.ts create mode 100644 packages/app-page-builder/src/blockEditor/plugins/elementVariables/quote/index.ts create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/MultipleImageVariableInput.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/QuoteVariableInput.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/RichVariableInput.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/SingleImageVariableInput.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/variable/TextVariableInput.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/button/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/heading/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/image/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/images-list/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/index.ts create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/list/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/paragraph/index.tsx create mode 100644 packages/app-page-builder/src/editor/plugins/elementVariables/quote/index.tsx create mode 100644 packages/app-page-builder/src/hooks/useVariable.ts create mode 100644 packages/app-page-builder/src/pageEditor/config/elements/ImageContainerPlugin.tsx create mode 100644 packages/app-page-builder/src/pageEditor/config/elements/ImagesListPlugin.tsx create mode 100644 packages/app-page-builder/src/pageEditor/config/elements/index.ts diff --git a/packages/app-page-builder/src/blockEditor/Editor.tsx b/packages/app-page-builder/src/blockEditor/Editor.tsx index 910d3a7b53a..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"; @@ -16,8 +17,10 @@ import { BlockEditorConfig } from "./config/BlockEditorConfig"; import { BlockWithContent } from "~/blockEditor/state"; 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(); diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx index 5a034afbb6f..0d8e371a13d 100644 --- a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/ElementNotLinked.tsx @@ -4,7 +4,7 @@ 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"; -const ElementNotLinkedWrapper = styled("div")({ +export const ElementLinkStatusWrapper = styled("div")({ padding: "16px", display: "grid", rowGap: "16px", @@ -28,7 +28,7 @@ const ElementNotLinkedWrapper = styled("div")({ 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. @@ -40,7 +40,7 @@ const ElementNotLinked = () => {
Click here to learn more about how block variables work
-
+ ); }; 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 index 02a6ce65f0e..fdc7111a291 100644 --- a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariableSettings.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useMemo } from "react"; import styled from "@emotion/styled"; -import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; +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 { Form } from "@webiny/form"; -import { validation } from "@webiny/validation"; -import { Input } from "@webiny/ui/Input"; +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", @@ -19,34 +20,39 @@ const VariableSettings = ({ element }: { element: PbEditorElement }) => { const { block } = useCurrentBlockElement(); const updateElement = useUpdateElement(); - const initialData = useMemo(() => { - const variable = block?.data?.variables?.find( - (variable: PbBlockVariable) => variable.id === element?.data?.variableId + const elementVariables = useMemo(() => { + const variables = block?.data?.variables?.filter( + (variable: PbBlockVariable) => variable.id.split(".")[0] === element?.data?.variableId ); - return { label: variable?.label }; + return variables; }, [block, element]); - const onSubmit = useCallback( - formData => { + const onChange = useCallback( + (label: string, variableId: string) => { if (block && block.id) { const newVariables = block.data?.variables?.map((variable: PbBlockVariable) => { - if (variable?.id === element?.data?.variableId) { + if (variable?.id === variableId) { return { ...variable, - label: formData.label + label }; } else { return variable; } }); - updateElement({ - ...block, - data: { - ...block.data, - variables: newVariables + updateElement( + { + ...block, + data: { + ...block.data, + variables: newVariables + } + }, + { + history: false } - }); + ); } }, [block, element] @@ -62,7 +68,8 @@ const VariableSettings = ({ element }: { element: PbEditorElement }) => { showConfirmation(() => { if (block && block.id) { const updatedVariables = block.data.variables.filter( - (variable: PbBlockVariable) => variable.id !== element?.data?.variableId + (variable: PbBlockVariable) => + variable.id.split(".")[0] !== element?.data?.variableId ); updateElement({ ...block, @@ -75,34 +82,44 @@ const VariableSettings = ({ element }: { element: PbEditorElement }) => { // element "variableId" value should be dropped // eslint-disable-next-line @typescript-eslint/no-unused-vars const { variableId, ...updatedElementData } = element.data; - updateElement({ - ...element, - data: updatedElementData - }); + updateElement( + { + ...element, + data: updatedElementData + }, + { + history: false + } + ); } }), [block, element] ); return ( -
- {({ data, form, Bind }) => ( - - - - - { - form.submit(ev); - }} - > - Save - - Remove Variable - - )} -
+ <> + + {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 +
+
+ ); }; diff --git a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx index 03dfc6e62d1..ddd2e2c6263 100644 --- a/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx +++ b/packages/app-page-builder/src/blockEditor/components/elementSettingsTab/VariablesList.tsx @@ -1,13 +1,18 @@ -import React from "react"; +import React, { useCallback } from "react"; import styled from "@emotion/styled"; -import { PbEditorElement, PbBlockVariable } from "~/types"; +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", @@ -23,7 +28,11 @@ const VariableItem = styled("div")({ "&:hover": { backgroundColor: "var(--mdc-theme-background)", - color: "var(--mdc-theme-primary)" + color: "var(--mdc-theme-primary)", + + "&>div": { + display: "block" + } }, "&>svg": { @@ -31,6 +40,25 @@ const VariableItem = styled("div")({ } }); +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; @@ -63,11 +91,13 @@ const getHighlightItemProps = ({ const VariablesListItem = ({ variable, index, - move + move, + onRemove }: { variable: PbBlockVariable; index: number; move: (current: number, next: number) => void; + onRemove: (variableId: string) => void; }) => { const { ref: dragAndDropRef, @@ -91,22 +121,83 @@ const VariablesListItem = ({ {variable?.label} + + onRemove(variable.id)} /> + ); }; -const VariablesList = ({ element }: { element: PbEditorElement }) => { - const { move } = useMoveVariable(element); +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 - {element?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( - + {block?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( + ))} ); diff --git a/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx b/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx index c1f506ccd70..be9d2df714f 100644 --- a/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx +++ b/packages/app-page-builder/src/blockEditor/config/ElementSettingsTabContentPlugin.tsx @@ -1,24 +1,32 @@ 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 && element.type === "heading"; + 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} )} 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 09143b8964b..9121b5cc91a 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,5 +1,6 @@ // 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"; @@ -7,33 +8,43 @@ 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, PbElementDataType, PbBlockVariable } from "~/types"; - -function findNestedObj( - entireObj: PbElement[], - keyToFind: string, - valToFind: string -): PbElementDataType | undefined { - let foundObj; - JSON.stringify(entireObj, (_, nestedValue) => { - if (nestedValue && nestedValue[keyToFind] === valToFind) { - foundObj = nestedValue; +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; + } } - return nestedValue; - }); - return foundObj; -} + } +}; 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 dataObject = findNestedObj(block.elements, "variableId", variable.id); + const element = findElementByVariableId(block.elements, variable.id.split(".")[0]); + const createVariablePlugin = createVariablePlugins.find( + plugin => plugin.elementType === element?.type + ); - if (dataObject) { - result.push({ ...variable, value: dataObject?.text?.data?.text }); + if (createVariablePlugin) { + result.push({ + ...variable, + value: createVariablePlugin.getVariableValue({ element, variableId: variable.id }) + }); } + return result; }, []); 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 8634e617bb7..d74c5a20901 100644 --- a/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts +++ b/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts @@ -1,15 +1,12 @@ import { UpdateDocumentActionArgsType } from "~/editor/recoil/actions"; import { BlockAtomType } from "~/blockEditor/state"; import { BlockEventActionCallable } from "~/blockEditor/types"; -import { ToggleSaveBlockStateActionEvent } from "./saveBlock/event"; export const updateBlockAction: BlockEventActionCallable< UpdateDocumentActionArgsType -> = async (state, meta, args) => { +> = async (state, _, args) => { console.log("updateBlockAction", state, args); - meta.eventActionHandler.trigger(new ToggleSaveBlockStateActionEvent({ saving: true })); - return { state: { block: { diff --git a/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts index b8de0ca37a5..e71374450d6 100644 --- a/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts +++ b/packages/app-page-builder/src/blockEditor/plugins/elementSettings/CreateVariableAction.ts @@ -1,9 +1,9 @@ import React, { useCallback } from "react"; -import startCase from "lodash/startCase"; -import camelCase from "lodash/camelCase"; +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; @@ -15,7 +15,18 @@ const CreateVariableAction: React.FC = ({ childre const updateElement = useUpdateElement(); const onClick = useCallback((): void => { - if (element && !element.data?.variableId && block && block.id) { + 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 } @@ -26,10 +37,7 @@ const CreateVariableAction: React.FC = ({ childre ...block.data, variables: [ ...(block.data?.variables || []), - { - id: element.id, - label: startCase(camelCase(element.type)) - } + ...variablePlugin.createVariables({ element }) ] } }); 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/editor/components/MediumEditor/index.ts b/packages/app-page-builder/src/editor/components/MediumEditor/index.ts index cbe8a49ba58..1e9ce3eed25 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]); return createElement(tagName, { dangerouslySetInnerHTML: { __html: value }, diff --git a/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts index 18fbb180ebc..b4d240a0469 100644 --- a/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts +++ b/packages/app-page-builder/src/editor/hooks/useElementVariableValue.ts @@ -1,16 +1,22 @@ import { useMemo } from "react"; -import { PbBlockVariable, PbEditorElement } from "~/types"; +import { plugins } from "@webiny/plugins"; +import { + PbBlockVariable, + PbEditorElement, + PbEditorPageElementVariableRendererPlugin +} from "~/types"; import { useParentBlock } from "~/editor/hooks/useParentBlock"; -export function useElementVariableValue(element: PbEditorElement | null) { +export function useElementVariables(element: PbEditorElement | null) { const block = useParentBlock(element?.id); const variableValue = useMemo(() => { if (element?.data?.variableId) { - const variable = block?.data?.variables?.find( - (variable: PbBlockVariable) => variable.id === element?.data?.variableId + const variable = block?.data?.variables?.filter( + (variable: PbBlockVariable) => + variable.id.split(".")[0] === element?.data?.variableId ); - return variable?.value; + return variable; } else { return null; } @@ -18,3 +24,16 @@ export function useElementVariableValue(element: PbEditorElement | null) { 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/index.tsx b/packages/app-page-builder/src/editor/index.tsx index 92d1170849c..7eae17d6b2d 100644 --- a/packages/app-page-builder/src/editor/index.tsx +++ b/packages/app-page-builder/src/editor/index.tsx @@ -17,3 +17,5 @@ export { SidebarActions } from "./components/Editor/Sidebar/ElementSettingsTabCo 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/elementSettings/delete/DeleteAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts index 88e3d7d82e8..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 @@ -10,7 +10,7 @@ import { useParentBlock } from "~/editor/hooks/useParentBlock"; const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => { const updatedVariables = block.data.variables.filter( - (variable: PbBlockVariable) => variable.id !== variableId + (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId ); return { diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/MultipleImageVariableInput.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/MultipleImageVariableInput.tsx new file mode 100644 index 00000000000..1079e19d145 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/MultipleImageVariableInput.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { css } from "emotion"; +/** + * Package react-sortable does not have types. + */ +// @ts-ignore +import { sortable } from "react-sortable"; +import { cloneDeep } from "lodash"; +import { FileManager } from "@webiny/app-admin/components"; +import File from "~/editor/plugins/elements/imagesList/File"; +import { + SimpleButton, + ButtonContainer +} from "~/editor/plugins/elementSettings/components/StyledComponents"; +import { useVariable } from "~/hooks/useVariable"; + +const style = { + addImagesButton: css({ clear: "both", padding: "20px 10px", textAlign: "center" }), + liItem: { + display: "inline-block" + } +}; + +class Item extends React.Component { + public override render() { + return ( +
  • + {this.props.children} +
  • + ); + } +} + +const SortableItem = sortable(Item); + +interface MultipleImageVariableInputProps { + variableId: string; +} + +const MultipleImageVariableInput: React.FC = ({ variableId }) => { + const { value, onChange } = useVariable(variableId); + + // TODO: Update to use the new `render` prop, implemented in 5.33.0. + return ( + { + const filesArray = Array.isArray(files) ? files : [files]; + Array.isArray(value) + ? onChange([...value, ...filesArray], true) + : onChange([...filesArray], true); + }} + > + {({ showFileManager }) => ( + <> +
      + {Array.isArray(value) && + value.map((item, i) => ( + onChange(val, true)} + // Clone item so lib can modify it + items={cloneDeep(value)} + sortId={i} + > + { + // Remove the image at index i + const updatedValue = [ + ...value.slice(0, i), + ...value.slice(i + 1) + ]; + onChange(updatedValue, true); + }} + /> + + ))} +
    + + showFileManager()}>Add images... + + + )} +
    + ); +}; + +export default MultipleImageVariableInput; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/QuoteVariableInput.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/QuoteVariableInput.tsx new file mode 100644 index 00000000000..1fe1fbbeee0 --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/QuoteVariableInput.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from "react"; +import { Input } from "@webiny/ui/Input"; +import { useVariable } from "~/hooks/useVariable"; + +const textToBlockQuote = (text: string) => { + return `
    ${text}
    `; +}; + +const blockQuoteToText = (text: string) => { + return text.replace("
    ", "").replace("
    ", ""); +}; + +interface QuoteVariableInputProps { + variableId: string; +} + +const QuoteVariableInput: React.FC = ({ variableId }) => { + const { value, onChange, onBlur } = useVariable(variableId); + const [localValue, setLocalValue] = useState(blockQuoteToText(value)); + + useEffect(() => { + if (localValue !== value) { + setLocalValue(value); + } + }, [value]); + + return ( + { + 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 index c177a10fb42..2a357992401 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/variable/VariableSettings.tsx @@ -1,68 +1,44 @@ -import React, { useCallback } from "react"; -import styled from "@emotion/styled"; -import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; +import React from "react"; +import { css } from "emotion"; +import { plugins } from "@webiny/plugins"; import { useActiveElement } from "~/editor/hooks/useActiveElement"; -import { Input } from "@webiny/ui/Input"; -import { PbBlockVariable } from "~/types"; +import { Typography } from "@webiny/ui/Typography"; +import { PbBlockVariable, PbEditorPageElementVariableRendererPlugin } from "~/types"; -const VariableSettingsWrapper = styled("div")({ +const wrapperStyle = css({ padding: "16px", display: "grid", - rowGap: "16px" + rowGap: "20px" +}); + +const labelStyle = css({ + marginBottom: "8px", + "& span": { + color: "var(--mdc-theme-text-primary-on-background)" + } }); const VariableSettings: React.FC = () => { const [element] = useActiveElement(); - const updateElement = useUpdateElement(); - - const onChange = useCallback( - (value: string, variableId: string) => { - if (element) { - const newVariables = element?.data?.variables?.map((variable: PbBlockVariable) => { - if (variable?.id === variableId) { - return { - ...variable, - value - }; - } else { - return variable; - } - }); - updateElement( - { - ...element, - data: { - ...element.data, - variables: newVariables - } - }, - { - history: false - } - ); - } - }, - [element, updateElement] - ); - const onBlur = useCallback(() => { - if (element) { - updateElement(element); - } - }, [element, updateElement]); + const elementVariableRendererPlugins = + plugins.byType( + "pb-editor-page-element-variable-renderer" + ); return ( - +
    {element?.data?.variables?.map((variable: PbBlockVariable, index: number) => ( - onChange(value, variable.id)} - onBlur={onBlur} - /> +
    +
    + {variable.label} +
    + {elementVariableRendererPlugins + .find(plugin => plugin.elementType === variable?.type) + ?.renderVariableInput(variable.id)} +
    ))} - +
    ); }; 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/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/image/ImageContainer.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx index 0e9646066a0..a9764602d8f 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 @@ -11,6 +11,7 @@ import { import { uiAtom } from "~/editor/recoil/modules"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; +import { makeComposable } from "@webiny/react-composition"; const AlignImage = styled("div")((props: any) => ({ img: { @@ -87,4 +88,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/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/pageEditor/Editor.tsx b/packages/app-page-builder/src/pageEditor/Editor.tsx index 0ef7e24020d..925a4bd70ef 100644 --- a/packages/app-page-builder/src/pageEditor/Editor.tsx +++ b/packages/app-page-builder/src/pageEditor/Editor.tsx @@ -1,5 +1,6 @@ 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"; @@ -29,6 +30,7 @@ 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; @@ -60,6 +62,7 @@ const getBlocksWithUniqueElementIds = (blocks: PbEditorElement[]): PbEditorEleme }; export const PageEditor: React.FC = () => { + plugins.register(elementVariableRendererPlugins()); const client = useApolloClient(); const { history, params } = useRouter(); const { showSnackbar } = useSnackbar(); diff --git a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx index 9ca1d646c53..25bbc978a0c 100644 --- a/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx +++ b/packages/app-page-builder/src/pageEditor/config/PageEditorConfig.tsx @@ -1,5 +1,6 @@ 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"; @@ -16,6 +17,8 @@ export const PageEditorConfig = React.memo(() => { + + ); }); 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/helpers.ts b/packages/app-page-builder/src/pageEditor/helpers.ts index d7750fd688d..63f8013df64 100644 --- a/packages/app-page-builder/src/pageEditor/helpers.ts +++ b/packages/app-page-builder/src/pageEditor/helpers.ts @@ -1,7 +1,13 @@ import invariant from "invariant"; import { plugins } from "@webiny/plugins"; import { getNanoid, addElementId } from "~/editor/helpers"; -import { PbEditorBlockPlugin, PbEditorElement, PbElement, PbBlockVariable } from "~/types"; +import { + PbEditorBlockPlugin, + PbEditorElement, + PbElement, + PbBlockVariable, + PbEditorPageElementVariableRendererPlugin +} from "~/types"; export const createBlockReference = (name: string): PbEditorElement => { const plugin = plugins.byName(name); @@ -32,13 +38,21 @@ export const removeElementVariableIds = ( ): PbElement => { el.elements = el.elements.map(el => { if (el.data?.variableId) { - const variableValue = variables.find( - (variable: PbBlockVariable) => variable.id === el.data.variableId - )?.value; + 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; - if (el.data?.text?.data?.text && variableValue) { - el.data.text.data.text = variableValue; - } // @ts-ignore delete el.data?.variableId; } diff --git a/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx index e558cd84214..ec629d1c261 100644 --- a/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx +++ b/packages/app-page-builder/src/pageEditor/plugins/blocks/emptyBlock.tsx @@ -8,7 +8,7 @@ const height = 117; const aspectRatio = width / height; const plugin: PbEditorBlockPlugin = { - name: "pb-editor-empty-block", + name: "pb-editor-block-empty", type: "pb-editor-block", blockCategory: "general", title: "Empty block", diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index 7abce72117b..e8b5059e44d 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -206,12 +206,28 @@ export interface PbElement { text?: string; } -export interface PbBlockVariable { +export interface PbBlockVariable { id: string; + type: string; label: string; - value?: 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 */ 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 From e451839cbc87ed1ba8b024e56e4f2c291ea03700 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Wed, 2 Nov 2022 17:57:29 +0200 Subject: [PATCH 30/56] fix: fix page background bleeds into the form (#2744) --- apps/theme/formBuilder/styles/theme.scss | 1 + 1 file changed, 1 insertion(+) 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 { From 03f0569528b2004ec53d92a06b83c6677c7361d1 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:20:10 +0200 Subject: [PATCH 31/56] fix: fix too big favicon image editor (#2745) --- .../modules/WebsiteSettings/settingsGroups/FaviconAndLogo.tsx | 3 +++ 1 file changed, 3 insertions(+) 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{" "} From 4443b49c0ccfb6edf2278c8299dd00e3867a8249 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:24:36 +0200 Subject: [PATCH 32/56] fix: fix missing error message when deleting a Page Category that already has pages (#2749) --- .../src/admin/views/Categories/CategoriesDataList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From 755b5de0efd273e5c9eee1537e79e3f02ffbd21f Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:25:54 +0200 Subject: [PATCH 33/56] fix: fix incorrect error thrown by the createCategory Mutation (#2748) --- .../api-page-builder/src/graphql/crud/categories.crud.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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(); From c031558c8d5a3de2749bbea8d42c58e37b4af4d7 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:27:44 +0200 Subject: [PATCH 34/56] fix: fix incorrect label on Pages export (#2750) --- .../ExportPageButton/ExportPageLoadingDialogContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}` })} Date: Fri, 4 Nov 2022 16:28:41 +0200 Subject: [PATCH 35/56] fix: fix breaking page rendering if main menu absent (#2743) * fix: fix breaking page rendering if main menu absent * fix: update missed cwp-template-aws Menu component --- apps/theme/pageBuilder/components/Menu.tsx | 2 +- .../template/common/apps/theme/pageBuilder/components/Menu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 ); }; From 9d6c51c6c4b513444b7b9b490dde8eda19332e56 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 18:36:03 +0200 Subject: [PATCH 36/56] fix: when pasting text, ensure it's pasted in a new row * fix: fix pasting into paragraph/text editor component * fix: fix code review comments --- .../src/editor/components/Text/PbText.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 78d75137ee4..b18c4c99ab9 100644 --- a/packages/app-page-builder/src/editor/components/Text/PbText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PbText.tsx @@ -38,6 +38,11 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro [displayMode] ); + const initialText = useMemo( + () => variableValue || get(element, `${DATA_NAMESPACE}.data.text`), + [] + ); + const value = get(element, `${DATA_NAMESPACE}.${displayMode}`, fallbackValue); const onChange = useCallback( @@ -59,7 +64,6 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro return null; } - const textContent = variableValue || get(element, `${DATA_NAMESPACE}.data.text`); const tag = get(value, "tag"); const typography = get(value, "typography"); @@ -71,7 +75,7 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro Date: Fri, 4 Nov 2022 21:25:34 +0200 Subject: [PATCH 37/56] fix: remove padding from selected grid cells (#2746) * fix: remove padding from selected grid cells * fix: remove Block padding --- .../app-page-builder/src/editor/plugins/elements/block/Block.tsx | 1 - .../src/editor/plugins/elements/grid/GridContainer.tsx | 1 - 2 files changed, 2 deletions(-) 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/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" }); From 214502b9e39a5168c08d5cd4a42a6b5b2f68576b Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 4 Nov 2022 21:36:53 +0200 Subject: [PATCH 38/56] fix: fix long page title bleeds into control options (#2755) --- .../plugins/pageDetails/header/Header.tsx | 42 +++++++++---------- .../config/editorBar/Title/Styled.ts | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) 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/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")({ From 526827a3db57144f5242f1bf2f9face141d8736a Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sat, 5 Nov 2022 15:20:52 +0100 Subject: [PATCH 39/56] fix(app-page-builder): move resolvePageBlocks to the app sdk --- .../src/graphql/crud/pageBlocks.crud.ts | 48 ++++++++++++++++++- .../src/graphql/graphql/pages.gql.ts | 3 +- .../graphql/utils/resolvePageBlocks.ts | 43 ----------------- .../api-page-builder/src/graphql/types.ts | 1 + 4 files changed, 49 insertions(+), 46 deletions(-) delete mode 100644 packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index cef75356dc1..80f93cf63d3 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -26,7 +26,8 @@ import { PageBlock, PageBlocksCrud, PageBlockStorageOperationsListParams, - PbContext + PbContext, + Page } from "~/types"; import checkBasePermissions from "./utils/checkBasePermissions"; import checkOwnPermissions from "./utils/checkOwnPermissions"; @@ -312,6 +313,51 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl } ); } + }, + 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 reference block and we need to get elements for it + if (blockId) { + const blockData = await storageOperations.pageBlocks.get({ + where: { + tenant: getTenantId(), + locale: getLocaleCode(), + id: blockId + } + }); + // We check if block has variable values set on the page and use them in priority over ones, + // that are set in blockEditor + 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({ + ...pageBlock, + data: { + blockId, + ...blockData?.content?.data, + variables + }, + elements: blockData?.content?.elements || [] + }); + } else { + blocks.push(pageBlock); + } + } + + return blocks; } }; }; 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 ac34f40c366..7198b062ed0 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -8,7 +8,6 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; import { Page, PbContext, PageSecurityPermission } from "~/types"; import WebinyError from "@webiny/error"; import resolve from "./utils/resolve"; -import resolvePageBlocks from "./utils/resolvePageBlocks"; import { createPageSettingsGraphQL } from "./pages/pageSettings"; import { fetchEmbed, findProvider } from "./pages/oEmbed"; import lodashGet from "lodash/get"; @@ -284,7 +283,7 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { if (!page.content?.elements) { return page.content; } - const blocks = await resolvePageBlocks(page, context); + const blocks = await context.pageBuilder.resolvePageBlocks(page); return { ...page.content, elements: blocks }; } }, diff --git a/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts b/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts deleted file mode 100644 index f66eecaa2ca..00000000000 --- a/packages/api-page-builder/src/graphql/graphql/utils/resolvePageBlocks.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Page, PbContext } from "~/types"; - -const resolvePageBlocks = async (page: Page, context: PbContext) => { - const blocks = []; - - for (const pageBlock of page.content?.elements) { - const blockId = pageBlock.data?.blockId; - // If block has blockId, then it is reference block and we need to get elements for it - if (blockId) { - const blockData = await context.pageBuilder.getPageBlock(blockId); - // We check if block has variable values set on the page and use them in priority over ones, - // that are set in blockEditor - 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({ - ...pageBlock, - data: { - blockId, - ...blockData?.content?.data, - variables - }, - elements: blockData?.content?.elements || [] - }); - } else { - blocks.push(pageBlock); - } - } - - return blocks; -}; - -export default resolvePageBlocks; diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index 17e7bf32e7f..dbf4b587a4b 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -626,6 +626,7 @@ export interface PageBlocksCrud { createPageBlock(data: Record): Promise; updatePageBlock(id: string, data: Record): Promise; deletePageBlock(id: string): Promise; + resolvePageBlocks(page: Page): Promise; /** * Lifecycle events From 874fa2a13a991b7fb6425f5dd0ef370a954654f8 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sat, 5 Nov 2022 15:55:16 +0100 Subject: [PATCH 40/56] fix(app-page-builder): remove zoom log --- .../src/admin/plugins/pageDetails/previewContent/PagePreview.tsx | 1 - 1 file changed, 1 deletion(-) 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 (
    Date: Sat, 5 Nov 2022 15:55:35 +0100 Subject: [PATCH 41/56] fix(app-page-builder): pass variable value as memo dependency --- .../app-page-builder/src/editor/components/Text/PbText.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b18c4c99ab9..8ea93ecf2d6 100644 --- a/packages/app-page-builder/src/editor/components/Text/PbText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PbText.tsx @@ -9,7 +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"; +import { useElementVariableValue } from "~/hooks/useElementVariableValue"; export const textClassName = "webiny-pb-base-page-element-style webiny-pb-page-element-text"; const DATA_NAMESPACE = "data.text"; @@ -40,7 +40,7 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro const initialText = useMemo( () => variableValue || get(element, `${DATA_NAMESPACE}.data.text`), - [] + [variableValue] ); const value = get(element, `${DATA_NAMESPACE}.${displayMode}`, fallbackValue); From 06dd8a195c57370dba34adcce58a08f98df179a3 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sat, 5 Nov 2022 19:45:56 +0100 Subject: [PATCH 42/56] fix(app-page-builder): remove console.logs --- .../admin/plugins/pageDetails/header/deletePage/DeletePage.tsx | 1 - .../config/eventActions/saveBlock/saveBlockAction.ts | 1 - .../src/blockEditor/config/eventActions/updateBlockAction.ts | 2 -- 3 files changed, 4 deletions(-) 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/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts b/packages/app-page-builder/src/blockEditor/config/eventActions/saveBlock/saveBlockAction.ts index 9121b5cc91a..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 @@ -102,7 +102,6 @@ export const saveBlockAction: BlockEventActionCallable }); await new Promise(resolve => { - console.log("Saving block", data); setTimeout(resolve, 500); }); 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 d74c5a20901..198712040eb 100644 --- a/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts +++ b/packages/app-page-builder/src/blockEditor/config/eventActions/updateBlockAction.ts @@ -5,8 +5,6 @@ import { BlockEventActionCallable } from "~/blockEditor/types"; export const updateBlockAction: BlockEventActionCallable< UpdateDocumentActionArgsType > = async (state, _, args) => { - console.log("updateBlockAction", state, args); - return { state: { block: { From 82d06bb43a7e68a2dccb79f76a8cc9db79ea1d46 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sat, 5 Nov 2022 21:42:20 +0100 Subject: [PATCH 43/56] fix(api-page-builder): add support for element processors --- .../src/graphql/crud/pageBlocks.crud.ts | 69 ++++++++++--------- .../src/graphql/crud/pages.crud.ts | 10 +++ .../graphql/crud/pages/processPageContent.ts | 31 +++++++++ .../src/graphql/elementProcessors/button.ts | 25 +++++++ .../src/graphql/elementProcessors/image.ts | 19 +++++ .../src/graphql/elementProcessors/images.ts | 19 +++++ .../src/graphql/elementProcessors/index.ts | 13 ++++ .../graphql/elementProcessors/paragraph.ts | 21 ++++++ .../src/graphql/elementProcessors/quote.ts | 25 +++++++ .../elementProcessors/useElementVariables.ts | 14 ++++ .../src/graphql/graphql/pages.gql.ts | 14 +++- .../api-page-builder/src/graphql/index.ts | 3 +- .../api-page-builder/src/graphql/types.ts | 37 +++++++--- 13 files changed, 256 insertions(+), 44 deletions(-) create mode 100644 packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/button.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/image.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/images.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/index.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/paragraph.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/quote.ts create mode 100644 packages/api-page-builder/src/graphql/elementProcessors/useElementVariables.ts diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index 80f93cf63d3..4d443eeea8d 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -319,42 +319,43 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl for (const pageBlock of page.content?.elements) { const blockId = pageBlock.data?.blockId; - // If block has blockId, then it is reference block and we need to get elements for it - if (blockId) { - const blockData = await storageOperations.pageBlocks.get({ - where: { - tenant: getTenantId(), - locale: getLocaleCode(), - id: blockId - } - }); - // We check if block has variable values set on the page and use them in priority over ones, - // that are set in blockEditor - 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({ - ...pageBlock, - data: { - blockId, - ...blockData?.content?.data, - variables - }, - elements: blockData?.content?.elements || [] - }); - } else { + // 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({ + ...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..3da3250ee2a --- /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/pages.gql.ts b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts index 7198b062ed0..33556028a77 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -283,8 +283,20 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { if (!page.content?.elements) { return page.content; } + + // Map block references const blocks = await context.pageBuilder.resolvePageBlocks(page); - return { ...page.content, elements: blocks }; + 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 dbf4b587a4b..ccb5eb37272 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -179,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, @@ -682,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"; From 0e0d088867106b4e166956a2dde6bb2e9db73acd Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sat, 5 Nov 2022 21:59:55 +0100 Subject: [PATCH 44/56] fix(api-page-builder): revert import path --- packages/app-page-builder/src/editor/components/Text/PbText.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8ea93ecf2d6..2ad3a6b80c3 100644 --- a/packages/app-page-builder/src/editor/components/Text/PbText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PbText.tsx @@ -9,7 +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 "~/hooks/useElementVariableValue"; +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"; From 956625a786fca1e99ee0bf5d08c961c55038dd89 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 7 Nov 2022 15:33:28 +0100 Subject: [PATCH 45/56] fix(app-page-builder): avoid nested p in Text element --- packages/app-page-builder/src/render/components/Text.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } })} From 1dca332896d9b14b5de1e33c9d585d6e4b4c8db9 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 7 Nov 2022 16:11:42 +0100 Subject: [PATCH 46/56] fix(api-page-builder): clone resolved block data --- .../src/graphql/crud/pageBlocks.crud.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts index 4d443eeea8d..8f099a06283 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks.crud.ts @@ -34,6 +34,7 @@ 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") }), @@ -347,15 +348,17 @@ export const createPageBlocksCrud = (params: CreatePageBlocksCrudParams): PageBl }; }); - blocks.push({ - ...pageBlock, - data: { - blockId, - ...blockData?.content?.data, - variables - }, - elements: blockData?.content?.elements || [] - }); + blocks.push( + cloneDeep({ + ...pageBlock, + data: { + blockId, + ...blockData?.content?.data, + variables + }, + elements: blockData?.content?.elements || [] + }) + ); } return blocks; From 21474401bb00816560c844861ee00901a2059c23 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 7 Nov 2022 18:14:41 +0200 Subject: [PATCH 47/56] fix: fix too strict Iframe component validation (#2756) --- .../elements/media/iframe/IFrameSettings.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx b/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx index 0aa7758a9c6..47672a0aeb0 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/media/iframe/IFrameSettings.tsx @@ -2,7 +2,6 @@ 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 "@webiny/ui/DelayedOnChange"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; @@ -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 => ( Date: Tue, 8 Nov 2022 00:44:38 +0200 Subject: [PATCH 48/56] fix: fix image default mobile/table alignment (#2757) --- .../elementSettings/elementSettingsUtils.ts | 5 +-- .../plugins/elements/image/ImageContainer.tsx | 39 ++++++++++--------- 2 files changed, 23 insertions(+), 21 deletions(-) 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/elements/image/ImageContainer.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/ImageContainer.tsx index a9764602d8f..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,17 +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: { @@ -19,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; } @@ -40,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) { From d0d3e28dc4fe8895241fac7887a14b044f46a1d7 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Tue, 8 Nov 2022 14:49:38 +0200 Subject: [PATCH 49/56] fix: fix corrupted undo state after deleting a block (#2758) --- .../editor/contexts/EventActionHandlerProvider.tsx | 12 +++++++++++- .../src/editor/plugins/toolbar/undoRedo/index.tsx | 11 ++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx index a27f04a5ab9..e8450370887 100644 --- a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx @@ -43,7 +43,7 @@ import { GetElementTreeProps } from "~/types"; import { composeAsync, composeSync, AsyncProcessor, SyncProcessor } from "@webiny/utils/compose"; -import { UpdateElementTreeActionEvent } from "~/editor/recoil/actions"; +import { UpdateElementTreeActionEvent, UpdateDocumentActionEvent } from "~/editor/recoil/actions"; type ListType = Map; type RegistryType = Map; @@ -150,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 => { @@ -375,6 +383,7 @@ export const EventActionHandlerProvider = makeComposable< goToSnapshot(previousSnapshot); snapshotsHistory.current.busy = false; updateElementTree(); + updateDocument(); }, redo: () => { if (snapshotsHistory.current.busy === true) { @@ -394,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/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 ( From 171f6a9a6482867ffbc2ca1d9cc45a6b32914e10 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 11 Nov 2022 23:03:02 +0200 Subject: [PATCH 50/56] fix: fix root cause of failing test (#2770) --- .../src/graphql/crud/pages/processPageContent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts b/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts index 3da3250ee2a..0b5776e94ee 100644 --- a/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts +++ b/packages/api-page-builder/src/graphql/crud/pages/processPageContent.ts @@ -25,7 +25,7 @@ async function traverse(element: PbPageElement, callback: ElementCallback) { await callback(element); } - for (const child of element.elements) { + for (const child of element.elements || []) { await traverse(child, callback); } } From fface192502e5e81d0a1e2091eca8f8624d76662 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 14 Nov 2022 12:28:21 +0200 Subject: [PATCH 51/56] fix: fix issue with px/% changes and cursor position (#2747) * fix: fix issue with px/% changes and cursor position * fix: issue with empty field value --- .../components/SpacingPicker.tsx | 30 ++++++++++++------- .../plugins/elementSettings/margin/index.ts | 10 ++++--- 2 files changed, 25 insertions(+), 15 deletions(-) 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/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; }; From fa5220714404c45ced1f85cdc872858a56b124d4 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 14 Nov 2022 12:40:24 +0200 Subject: [PATCH 52/56] feat: allow Grid customization according to a viewport size (#2763) * feat: allow Grid customization according to a viewport size * fix: rename grid options to grid settings --- .../src/plugins/pageBuilder/renderPlugins.ts | 2 + .../pageBuilder/styles/elements/layout.scss | 59 ++-- apps/website/src/plugins/pageBuilder.ts | 2 + .../elementSettings/grid/GridSettings.tsx | 252 ++++++------------ .../plugins/elementSettings/grid/GridSize.tsx | 197 ++++++++++++++ .../plugins/elementSettings/grid/index.tsx | 24 +- .../editor/plugins/elements/grid/index.tsx | 11 + .../plugins/elementSettings/grid/index.ts | 30 +++ .../src/plugins/pageBuilder/renderPlugins.ts | 2 + .../pageBuilder/styles/elements/layout.scss | 61 ++--- .../apps/website/src/plugins/pageBuilder.ts | 2 + 11 files changed, 377 insertions(+), 265 deletions(-) create mode 100644 packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx create mode 100644 packages/app-page-builder/src/render/plugins/elementSettings/grid/index.ts 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/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/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/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/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/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/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, From 4b9d640afc1bdc31f02720574389540cd64362df Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 14 Nov 2022 12:43:39 +0200 Subject: [PATCH 53/56] fix: fix Button link behavior with URLs without http/https (#2767) --- .../render/plugins/elements/button/Button.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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; } From d4af713831a7f9308aa5bd16826610aca9ceecbe Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 18 Nov 2022 14:02:17 +0200 Subject: [PATCH 54/56] fix: keep heading content when heading type is changed (#2771) --- .../src/editor/components/MediumEditor/index.ts | 2 +- packages/app-page-builder/src/editor/components/Text/PbText.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 1e9ce3eed25..d01910a3bd6 100644 --- a/packages/app-page-builder/src/editor/components/MediumEditor/index.ts +++ b/packages/app-page-builder/src/editor/components/MediumEditor/index.ts @@ -104,7 +104,7 @@ const ReactMediumEditor: React.FC = ({ editorRef.current.unsubscribe("blur", handleChange); editorRef.current.unsubscribe("editableInput", handleSelect); }; - }, [handleChange, handleSelect]); + }, [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 2ad3a6b80c3..3ef35c54286 100644 --- a/packages/app-page-builder/src/editor/components/Text/PbText.tsx +++ b/packages/app-page-builder/src/editor/components/Text/PbText.tsx @@ -40,7 +40,7 @@ const PbText: React.FC = ({ elementId, mediumEditorOptions, ro const initialText = useMemo( () => variableValue || get(element, `${DATA_NAMESPACE}.data.text`), - [variableValue] + [variableValue, element] ); const value = get(element, `${DATA_NAMESPACE}.${displayMode}`, fallbackValue); From 5325cbc125f264ba23c650ee0af04a6b1eb69b5b Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Fri, 18 Nov 2022 14:07:16 +0200 Subject: [PATCH 55/56] feat(app-page-builder): rename Media to Embeds (#2772) --- .../src/plugins/pageBuilder/editorPlugins.ts | 10 ++++++---- .../plugins/elementGroups/embeds/embeds.svg | 3 +++ .../plugins/elementGroups/embeds/index.tsx | 12 ++++++++++++ .../src/editor/plugins/elementGroups/index.ts | 12 +++++++++++- .../{media => embeds}/iframe/IFrame.tsx | 0 .../{media => embeds}/iframe/IFrameSettings.tsx | 0 .../{media => embeds}/iframe/iframe-icon.svg | 0 .../elements/{media => embeds}/iframe/index.tsx | 2 +- .../{media => embeds}/soundcloud/index.tsx | 2 +- .../soundcloud/soundcloud-brands.svg | 0 .../{media => embeds}/vimeo/VimeoEmbed.tsx | 0 .../elements/{media => embeds}/vimeo/index.tsx | 2 +- .../{media => embeds}/vimeo/vimeo-v-brands.svg | 0 .../{media => embeds}/youtube/YoutubeEmbed.tsx | 0 .../{media => embeds}/youtube/index.tsx | 2 +- .../{media => embeds}/youtube/placeholder.png | Bin .../youtube/youtube-brands.svg | 0 .../plugins/toolbar/addElement/AddElement.tsx | 16 +++++++++++++++- .../src/plugins/pageBuilder/editorPlugins.ts | 10 ++++++---- 19 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/app-page-builder/src/editor/plugins/elementGroups/embeds/embeds.svg create mode 100644 packages/app-page-builder/src/editor/plugins/elementGroups/embeds/index.tsx rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/iframe/IFrame.tsx (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/iframe/IFrameSettings.tsx (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/iframe/iframe-icon.svg (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/iframe/index.tsx (98%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/soundcloud/index.tsx (98%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/soundcloud/soundcloud-brands.svg (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/vimeo/VimeoEmbed.tsx (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/vimeo/index.tsx (98%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/vimeo/vimeo-v-brands.svg (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/youtube/YoutubeEmbed.tsx (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/youtube/index.tsx (98%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/youtube/placeholder.png (100%) rename packages/app-page-builder/src/editor/plugins/elements/{media => embeds}/youtube/youtube-brands.svg (100%) diff --git a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts index c18a71c4816..4ed03912cda 100644 --- a/apps/admin/src/plugins/pageBuilder/editorPlugins.ts +++ b/apps/admin/src/plugins/pageBuilder/editorPlugins.ts @@ -9,10 +9,10 @@ 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"; @@ -23,6 +23,7 @@ import heading from "@webiny/app-page-builder/editor/plugins/elements/heading"; 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"; @@ -89,6 +90,7 @@ export default [ formGroup, layoutGroup, mediaGroup, + embedsGroup, socialGroup, codeGroup, savedGroup, diff --git a/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/embeds.svg b/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/embeds.svg new file mode 100644 index 00000000000..fa1b559856e --- /dev/null +++ b/packages/app-page-builder/src/editor/plugins/elementGroups/embeds/embeds.svg @@ -0,0 +1,3 @@ + + + \ 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/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 100% 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 diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/iframe-icon.svg b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/iframe-icon.svg similarity index 100% rename from packages/app-page-builder/src/editor/plugins/elements/media/iframe/iframe-icon.svg rename to packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/iframe-icon.svg diff --git a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/index.tsx similarity index 98% rename from packages/app-page-builder/src/editor/plugins/elements/media/iframe/index.tsx rename to packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/index.tsx index 3aaabf7eab9..f0d864e4cf9 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/media/iframe/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/embeds/iframe/index.tsx @@ -28,7 +28,7 @@ export default () => { 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/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/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 36a3e384c19..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,13 +9,13 @@ 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"; @@ -23,6 +23,7 @@ import heading from "@webiny/app-page-builder/editor/plugins/elements/heading"; 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"; @@ -86,6 +87,7 @@ export default [ formGroup, layoutGroup, mediaGroup, + embedsGroup, socialGroup, codeGroup, savedGroup, From df6c805ca6ebdef828535e7cc1cdb7d4b2f4ee98 Mon Sep 17 00:00:00 2001 From: Vitalii Nobis <77202393+neatbyte-vnobis@users.noreply.github.com> Date: Mon, 21 Nov 2022 15:27:52 +0200 Subject: [PATCH 56/56] fix: fix Image component shadow (#2762) * fix: fix Image component shadow * fix: image shadow overflow issue --- apps/theme/pageBuilder/styles/base.scss | 2 ++ apps/theme/pageBuilder/styles/elements/image.scss | 3 ++- .../src/render/plugins/elementSettings/shadow/index.ts | 7 +++++-- .../common/apps/theme/pageBuilder/styles/base.scss | 2 ++ .../apps/theme/pageBuilder/styles/elements/image.scss | 3 ++- 5 files changed, 13 insertions(+), 4 deletions(-) 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/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/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