From 636f35576fafa565e780194fe65081947aa538db Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Tue, 10 May 2022 13:25:24 +0300 Subject: [PATCH 1/6] 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 0000000000..4f130991fd --- /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 98dd12f6a8..f0e317921b 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 0000000000..4096d6814b --- /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 0000000000..c8f7875582 --- /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 0000000000..cc8e4bb460 --- /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 0000000000..96846a8823 --- /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 0000000000..4331009c02 --- /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 d27da1c982..94111c02df 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 0000000000..4f130991fd --- /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 16b67cac13..3fcfa127ff 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 0000000000..626c8caaec --- /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 0000000000..994c89ad05 --- /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 0000000000..24cbb0481d --- /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 0000000000..96846a8823 --- /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 0000000000..fa6a105fb3 --- /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 214329f4ca..9f107813dd 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 855f204c15..cf6a9e3d4d 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 0000000000..d96333f5f5 --- /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 ab433940ab..fa4b58f131 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 0000000000..a59e7e5815 --- /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 a341a81e88..14d98723e8 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 1416f0c45c..9404942f97 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 79a086f3ac..5c4fce184a 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 d6bf77de17..d5cad7a0d7 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 8ed9c809d6..6057c3ea8a 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 0000000000..9bf7cb9b24 --- /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 0000000000..18e059ecc6 --- /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 0000000000..2bb7b7e405 --- /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 0000000000..40ac2be357 --- /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 926a09328b..cee2c4e984 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 2/6] 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 0000000000..6ecc297218 --- /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 0000000000..9d28a992b7 --- /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 0000000000..bc1dd66ee3 --- /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 8c11ddfc31..0c70572694 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 3/6] 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 0000000000..602481136b --- /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 b7cf55c4c2..b7895428a4 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 4/6] 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 2bb7b7e405..eb44132850 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 0000000000..b4c59871d6 --- /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 5/6] 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 b4c59871d6..f7f4d0557c 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 6/6] 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 6ecc297218..231e1d7052 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 9d28a992b7..5c5d03cfa0 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 d96333f5f5..8e082b2c76 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 f7f4d0557c..465d0be33e 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 0000000000..f5fc38539e --- /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 fa248ae9cc..1777919840 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 0000000000..ba6d1d51c6 --- /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')" + ); +};