diff --git a/apps/api/pageBuilder/export/combine/package.json b/apps/api/pageBuilder/export/combine/package.json index 410f647cc22..f35a474e724 100644 --- a/apps/api/pageBuilder/export/combine/package.json +++ b/apps/api/pageBuilder/export/combine/package.json @@ -8,6 +8,8 @@ "dependencies": { "@webiny/api-form-builder": "0.0.0", "@webiny/api-form-builder-so-ddb": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/api-headless-cms-ddb": "0.0.0", "@webiny/api-i18n": "0.0.0", "@webiny/api-i18n-content": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", diff --git a/apps/api/pageBuilder/export/combine/src/index.ts b/apps/api/pageBuilder/export/combine/src/index.ts index bd915284fb5..08fa83da13e 100644 --- a/apps/api/pageBuilder/export/combine/src/index.ts +++ b/apps/api/pageBuilder/export/combine/src/index.ts @@ -3,6 +3,8 @@ import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; import i18nContentPlugins from "@webiny/api-i18n-content/plugins"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { @@ -37,6 +39,18 @@ export const handler = createHandler({ i18nPlugins(), i18nDynamoDbStorageOperations(), i18nContentPlugins(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient diff --git a/apps/api/pageBuilder/export/combine/tsconfig.json b/apps/api/pageBuilder/export/combine/tsconfig.json index 2f6ae610547..10d1f16744c 100644 --- a/apps/api/pageBuilder/export/combine/tsconfig.json +++ b/apps/api/pageBuilder/export/combine/tsconfig.json @@ -4,6 +4,8 @@ "references": [ { "path": "../../../../../packages/api-form-builder/tsconfig.build.json" }, { "path": "../../../../../packages/api-form-builder-so-ddb/tsconfig.build.json" }, + { "path": "../../../../../packages/api-headless-cms/tsconfig.build.json" }, + { "path": "../../../../../packages/api-headless-cms-ddb/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n-content/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n-ddb/tsconfig.build.json" }, @@ -30,6 +32,10 @@ "../../../../../packages/api-form-builder-so-ddb/src/*" ], "@webiny/api-form-builder-so-ddb": ["../../../../../packages/api-form-builder-so-ddb/src"], + "@webiny/api-headless-cms/*": ["../../../../../packages/api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../../../../../packages/api-headless-cms/src"], + "@webiny/api-headless-cms-ddb/*": ["../../../../../packages/api-headless-cms-ddb/src/*"], + "@webiny/api-headless-cms-ddb": ["../../../../../packages/api-headless-cms-ddb/src"], "@webiny/api-i18n/*": ["../../../../../packages/api-i18n/src/*"], "@webiny/api-i18n": ["../../../../../packages/api-i18n/src"], "@webiny/api-i18n-content/*": ["../../../../../packages/api-i18n-content/src/*"], diff --git a/apps/api/pageBuilder/import/create/package.json b/apps/api/pageBuilder/import/create/package.json index f90557709cb..70c4253c499 100644 --- a/apps/api/pageBuilder/import/create/package.json +++ b/apps/api/pageBuilder/import/create/package.json @@ -8,6 +8,8 @@ "dependencies": { "@webiny/api-form-builder": "0.0.0", "@webiny/api-form-builder-so-ddb": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/api-headless-cms-ddb": "0.0.0", "@webiny/api-i18n": "0.0.0", "@webiny/api-i18n-content": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", diff --git a/apps/api/pageBuilder/import/create/src/index.ts b/apps/api/pageBuilder/import/create/src/index.ts index 5c2c531834d..7959e811f0b 100644 --- a/apps/api/pageBuilder/import/create/src/index.ts +++ b/apps/api/pageBuilder/import/create/src/index.ts @@ -3,6 +3,8 @@ import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; import i18nContentPlugins from "@webiny/api-i18n-content/plugins"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { @@ -35,6 +37,18 @@ export const handler = createHandler({ i18nPlugins(), i18nDynamoDbStorageOperations(), i18nContentPlugins(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient diff --git a/apps/api/pageBuilder/import/create/tsconfig.json b/apps/api/pageBuilder/import/create/tsconfig.json index 2f6ae610547..10d1f16744c 100644 --- a/apps/api/pageBuilder/import/create/tsconfig.json +++ b/apps/api/pageBuilder/import/create/tsconfig.json @@ -4,6 +4,8 @@ "references": [ { "path": "../../../../../packages/api-form-builder/tsconfig.build.json" }, { "path": "../../../../../packages/api-form-builder-so-ddb/tsconfig.build.json" }, + { "path": "../../../../../packages/api-headless-cms/tsconfig.build.json" }, + { "path": "../../../../../packages/api-headless-cms-ddb/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n-content/tsconfig.build.json" }, { "path": "../../../../../packages/api-i18n-ddb/tsconfig.build.json" }, @@ -30,6 +32,10 @@ "../../../../../packages/api-form-builder-so-ddb/src/*" ], "@webiny/api-form-builder-so-ddb": ["../../../../../packages/api-form-builder-so-ddb/src"], + "@webiny/api-headless-cms/*": ["../../../../../packages/api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../../../../../packages/api-headless-cms/src"], + "@webiny/api-headless-cms-ddb/*": ["../../../../../packages/api-headless-cms-ddb/src/*"], + "@webiny/api-headless-cms-ddb": ["../../../../../packages/api-headless-cms-ddb/src"], "@webiny/api-i18n/*": ["../../../../../packages/api-i18n/src/*"], "@webiny/api-i18n": ["../../../../../packages/api-i18n/src"], "@webiny/api-i18n-content/*": ["../../../../../packages/api-i18n-content/src/*"], diff --git a/packages/api-form-builder-so-ddb-es/package.json b/packages/api-form-builder-so-ddb-es/package.json index a2c7ce89e65..eb13f6aee59 100644 --- a/packages/api-form-builder-so-ddb-es/package.json +++ b/packages/api-form-builder-so-ddb-es/package.json @@ -34,7 +34,6 @@ "@webiny/db-dynamodb": "0.0.0", "@webiny/error": "0.0.0", "@webiny/plugins": "0.0.0", - "@webiny/utils": "0.0.0", "elastic-ts": "^0.8.0" }, "devDependencies": { diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/form.ts b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts index 194ef97fe25..ebf5ce3b39b 100644 --- a/packages/api-form-builder-so-ddb-es/src/definitions/form.ts +++ b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts @@ -73,9 +73,6 @@ export const createFormEntity = (params: Params): Entity => { steps: { type: "list" }, - stats: { - type: "map" - }, settings: { type: "map" }, diff --git a/packages/api-form-builder-so-ddb-es/src/index.ts b/packages/api-form-builder-so-ddb-es/src/index.ts index 1968a95b18d..2ba68d6be32 100644 --- a/packages/api-form-builder-so-ddb-es/src/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/index.ts @@ -1,40 +1,16 @@ -import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters"; -import formElasticsearchFields from "./operations/form/elasticsearchFields"; -import submissionElasticsearchFields from "./operations/submission/elasticsearchFields"; import WebinyError from "@webiny/error"; -import { ENTITIES, FormBuilderStorageOperationsFactory } from "~/types"; +import { FormBuilderStorageOperationsFactory, ENTITIES, FormBuilderContext } from "~/types"; import { createTable } from "~/definitions/table"; -import { createFormEntity } from "~/definitions/form"; -import { createSubmissionEntity } from "~/definitions/submission"; import { createSystemEntity } from "~/definitions/system"; import { createSettingsEntity } from "~/definitions/settings"; import { createSystemStorageOperations } from "~/operations/system"; import { createSubmissionStorageOperations } from "~/operations/submission"; import { createSettingsStorageOperations } from "~/operations/settings"; import { createFormStorageOperations } from "~/operations/form"; +import { createFormStatsStorageOperations } from "~/operations/formStats"; import { createElasticsearchTable } from "~/definitions/tableElasticsearch"; -import { PluginsContainer } from "@webiny/plugins"; -import { createElasticsearchEntity } from "~/definitions/elasticsearch"; -import { - CompressionPlugin, - ElasticsearchQueryBuilderOperatorPlugin -} from "@webiny/api-elasticsearch"; -import { elasticsearchIndexPlugins } from "~/elasticsearch/indices"; -import { createElasticsearchIndex } from "~/elasticsearch/createElasticsearchIndex"; -import { FormBuilderContext } from "@webiny/api-form-builder/types"; -import { - FormDynamoDbFieldPlugin, - FormElasticsearchBodyModifierPlugin, - FormElasticsearchFieldPlugin, - FormElasticsearchIndexPlugin, - FormElasticsearchQueryModifierPlugin, - FormElasticsearchSortModifierPlugin, - SubmissionElasticsearchBodyModifierPlugin, - SubmissionElasticsearchFieldPlugin, - SubmissionElasticsearchQueryModifierPlugin, - SubmissionElasticsearchSortModifierPlugin -} from "~/plugins"; import { createIndexTaskPlugin } from "~/tasks/createIndexTaskPlugin"; +import { elasticsearchIndexPlugins } from "~/elasticsearch/indices"; const reservedFields = ["PK", "SK", "index", "data", "TYPE", "__type", "GSI1_PK", "GSI1_SK"]; @@ -50,14 +26,7 @@ const isReserved = (name: string): void => { export * from "./plugins"; export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFactory = params => { - const { - attributes, - table: tableName, - esTable: esTableName, - documentClient, - elasticsearch, - plugins: userPlugins - } = params; + const { attributes, table: tableName, esTable: esTableName, documentClient } = params; if (attributes) { Object.values(attributes).forEach(attrs => { @@ -65,29 +34,6 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac }); } - const plugins = new PluginsContainer([ - /** - * User defined plugins. - */ - userPlugins || [], - /** - * Elasticsearch field definitions for the submission record. - */ - submissionElasticsearchFields(), - /** - * Elasticsearch field definitions for the form record. - */ - formElasticsearchFields(), - /** - * DynamoDB filter plugins for the where conditions. - */ - dynamoDbValueFilters(), - /** - * Built-in Elasticsearch index plugins - */ - elasticsearchIndexPlugins() - ]); - const table = createTable({ tableName, documentClient @@ -102,16 +48,6 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac /** * Regular entities. */ - form: createFormEntity({ - entityName: ENTITIES.FORM, - table, - attributes: attributes ? attributes[ENTITIES.FORM] : {} - }), - submission: createSubmissionEntity({ - entityName: ENTITIES.SUBMISSION, - table, - attributes: attributes ? attributes[ENTITIES.SUBMISSION] : {} - }), system: createSystemEntity({ entityName: ENTITIES.SYSTEM, table, @@ -121,55 +57,13 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac entityName: ENTITIES.SETTINGS, table, attributes: attributes ? attributes[ENTITIES.SETTINGS] : {} - }), - /** - * Elasticsearch entities. - */ - esForm: createElasticsearchEntity({ - entityName: ENTITIES.ES_FORM, - table: esTable, - attributes: attributes ? attributes[ENTITIES.ES_FORM] : {} - }), - esSubmission: createElasticsearchEntity({ - entityName: ENTITIES.ES_SUBMISSION, - table: esTable, - attributes: attributes ? attributes[ENTITIES.ES_SUBMISSION] : {} }) }; return { beforeInit: async (context: FormBuilderContext) => { - const types: string[] = [ - // Elasticsearch - CompressionPlugin.type, - ElasticsearchQueryBuilderOperatorPlugin.type, - // Form Builder - FormDynamoDbFieldPlugin.type, - FormElasticsearchBodyModifierPlugin.type, - FormElasticsearchFieldPlugin.type, - FormElasticsearchIndexPlugin.type, - FormElasticsearchQueryModifierPlugin.type, - FormElasticsearchSortModifierPlugin.type, - SubmissionElasticsearchBodyModifierPlugin.type, - SubmissionElasticsearchFieldPlugin.type, - SubmissionElasticsearchQueryModifierPlugin.type, - SubmissionElasticsearchSortModifierPlugin.type - ]; - for (const type of types) { - plugins.mergeByType(context.plugins, type); - } context.plugins.register([createIndexTaskPlugin(), elasticsearchIndexPlugins()]); }, - init: async (context: FormBuilderContext) => { - context.i18n.locales.onLocaleBeforeCreate.subscribe(async ({ locale, tenant }) => { - await createElasticsearchIndex({ - elasticsearch, - plugins, - tenant, - locale: locale.code - }); - }); - }, getTable: () => table, getEsTable: () => esTable, getEntities: () => entities, @@ -181,19 +75,8 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac table, entity: entities.settings }), - ...createFormStorageOperations({ - elasticsearch, - table, - entity: entities.form, - esEntity: entities.esForm, - plugins - }), - ...createSubmissionStorageOperations({ - elasticsearch, - table, - entity: entities.submission, - esEntity: entities.esSubmission, - plugins - }) + forms: createFormStorageOperations(), + formStats: createFormStatsStorageOperations(), + submissions: createSubmissionStorageOperations() }; }; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts index db1e3cf4c05..e6f596353a2 100644 --- a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts @@ -1,987 +1,64 @@ -import { - FbForm, - FormBuilderStorageOperationsCreateFormFromParams, - FormBuilderStorageOperationsCreateFormParams, - FormBuilderStorageOperationsDeleteFormParams, - FormBuilderStorageOperationsDeleteFormRevisionParams, - FormBuilderStorageOperationsGetFormParams, - FormBuilderStorageOperationsListFormRevisionsParams, - FormBuilderStorageOperationsListFormRevisionsParamsWhere, - FormBuilderStorageOperationsListFormsParams, - FormBuilderStorageOperationsListFormsResponse, - FormBuilderStorageOperationsPublishFormParams, - FormBuilderStorageOperationsUnpublishFormParams, - FormBuilderStorageOperationsUpdateFormParams -} from "@webiny/api-form-builder/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { Client } from "@elastic/elasticsearch"; -import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import WebinyError from "@webiny/error"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { configurations } from "~/configurations"; -import { filterItems } from "@webiny/db-dynamodb/utils/filter"; -import fields from "./fields"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { parseIdentifier, zeroPad } from "@webiny/utils"; -import { createElasticsearchBody, createFormElasticType } from "./elasticsearchBody"; -import { decodeCursor, encodeCursor } from "@webiny/api-elasticsearch"; -import { PluginsContainer } from "@webiny/plugins"; -import { FormBuilderFormCreateKeyParams, FormBuilderFormStorageOperations } from "~/types"; -import { ElasticsearchSearchResponse } from "@webiny/api-elasticsearch/types"; -import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; +import { FormBuilderFormStorageOperations } from "@webiny/api-form-builder/types"; -export type DbRecord = T & { - PK: string; - SK: string; - TYPE: string; -}; - -export interface CreateFormStorageOperationsParams { - entity: Entity; - esEntity: Entity; - table: Table; - elasticsearch: Client; - plugins: PluginsContainer; -} - -type FbFormElastic = Omit & { - __type: string; -}; - -const getESDataForLatestRevision = (form: FbForm): FbFormElastic => ({ - __type: createFormElasticType(), - id: form.id, - createdOn: form.createdOn, - savedOn: form.savedOn, - name: form.name, - slug: form.slug, - published: form.published, - publishedOn: form.publishedOn, - version: form.version, - locked: form.locked, - status: form.status, - createdBy: form.createdBy, - ownedBy: form.ownedBy, - tenant: form.tenant, - locale: form.locale, - webinyVersion: form.webinyVersion, - formId: form.formId -}); - -export const createFormStorageOperations = ( - params: CreateFormStorageOperationsParams -): FormBuilderFormStorageOperations => { - const { entity, esEntity, table, plugins, elasticsearch } = params; - - const formDynamoDbFields = fields(); - - const createFormPartitionKey = (params: FormBuilderFormCreateKeyParams): string => { - const { tenant, locale, id: targetId } = params; - - const { id } = parseIdentifier(targetId); - - return `T#${tenant}#L#${locale}#FB#F#${id}`; +export const createFormStorageOperations = (): FormBuilderFormStorageOperations => { + const createForm = () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const createRevisionSortKey = (value: string | number | undefined): string => { - const version = - typeof value === "number" ? Number(value) : (parseIdentifier(value).version as number); - return `REV#${zeroPad(version)}`; + const createFormFrom = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const createLatestSortKey = (): string => { - return "L"; - }; - - const createLatestPublishedSortKey = (): string => { - return "LP"; - }; - - const createFormType = (): string => { - return "fb.form"; - }; - - const createFormLatestType = (): string => { - return "fb.form.latest"; - }; - - const createFormLatestPublishedType = (): string => { - return "fb.form.latestPublished"; - }; - - const createForm = async ( - params: FormBuilderStorageOperationsCreateFormParams - ): Promise => { - const { form } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.id) - }; - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const items = [ - entity.putBatch({ - ...form, - TYPE: createFormType(), - ...revisionKeys - }), - entity.putBatch({ - ...form, - TYPE: createFormLatestType(), - ...latestKeys - }) - ]; - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not insert form data into regular table.", - ex.code || "CREATE_FORM_ERROR", - { - revisionKeys, - latestKeys, - form - } - ); - } - try { - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - await put({ - entity: esEntity, - item: { - index, - data: getESDataForLatestRevision(form), - TYPE: createFormType(), - ...latestKeys - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not insert form data into Elasticsearch table.", - ex.code || "CREATE_FORM_ERROR", - { - latestKeys, - form - } - ); - } - return form; - }; - - const createFormFrom = async ( - params: FormBuilderStorageOperationsCreateFormFromParams - ): Promise => { - const { form, original, latest } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.version) - }; - - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || - "Could not create form data in the regular table, from existing form.", - ex.code || "CREATE_FORM_FROM_ERROR", - { - revisionKeys, - latestKeys, - original, - form, - latest - } - ); - } - - try { - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - await put({ - entity: esEntity, - item: { - index, - data: getESDataForLatestRevision(form), - TYPE: createFormLatestType(), - ...latestKeys - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || - "Could not create form in the Elasticsearch table, from existing form.", - ex.code || "CREATE_FORM_FROM_ERROR", - { - latestKeys, - form, - latest, - original - } - ); - } - return form; - }; - - const updateForm = async ( - params: FormBuilderStorageOperationsUpdateFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.id) - }; - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const { formId, tenant, locale } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - const isLatestForm = latestForm ? latestForm.id === form.id : false; - - const items = [ - entity.putBatch({ - ...form, - TYPE: createFormType(), - ...revisionKeys - }) - ]; - if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - TYPE: createFormLatestType(), - ...latestKeys - }) - ); - } - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update form data in the regular table.", - ex.code || "UPDATE_FORM_ERROR", - { - revisionKeys, - latestKeys, - original, - form, - latestForm - } - ); - } - /** - * No need to go further if its not latest form. - */ - if (!isLatestForm) { - return form; - } - - try { - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - await put({ - entity: esEntity, - item: { - index, - data: getESDataForLatestRevision(form), - TYPE: createFormLatestType(), - ...latestKeys - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update form data in the Elasticsearch table.", - ex.code || "UPDATE_FORM_ERROR", - { - latestKeys, - form, - latestForm, - original - } - ); - } - return form; + const updateForm = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const getForm = async (params: FormBuilderStorageOperationsGetFormParams) => { - const { where } = params; - const { id, formId, latest, published, version, tenant, locale } = where; - if (latest && published) { - throw new WebinyError("Cannot have both latest and published params."); - } - let sortKey: string; - if (latest) { - sortKey = createLatestSortKey(); - } else if (published && !version) { - /** - * Because of the specifics how DynamoDB works, we must not load the published record if version is sent. - */ - sortKey = createLatestPublishedSortKey(); - } else if (id || version) { - sortKey = createRevisionSortKey(version || id); - } else { - throw new WebinyError( - "Missing parameter to create a sort key.", - "MISSING_WHERE_PARAMETER", - { - where - } - ); - } - - const keys = { - PK: createFormPartitionKey({ - tenant, - locale, - id: (formId || id) as string - }), - SK: sortKey - }; - - try { - return await getClean({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not get form by keys.", - ex.code || "GET_FORM_ERROR", - { - keys - } - ); - } + const getForm = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const listForms = async ( - params: FormBuilderStorageOperationsListFormsParams - ): Promise => { - const { sort, limit, where, after } = params; - - const body = createElasticsearchBody({ - plugins, - sort, - limit: limit + 1, - where, - after: decodeCursor(after) - }); - - const esConfig = configurations.es({ - tenant: where.tenant, - locale: where.locale - }); - - const query = { - ...esConfig, - body - }; - - let response: ElasticsearchSearchResponse; - try { - response = await elasticsearch.search(query); - } catch (ex) { - throw new WebinyError( - ex.message || "Could list forms.", - ex.code || "LIST_FORMS_ERROR", - { - where, - query - } - ); - } - - const { hits, total } = response.body.hits; - const items = hits.map(item => item._source); - - const hasMoreItems = items.length > limit; - if (hasMoreItems) { - /** - * Remove the last item from results, we don't want to include it. - */ - items.pop(); - } - /** - * Cursor is the `sort` value of the last item in the array. - * https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after - */ - - const meta = { - hasMoreItems, - totalCount: total.value, - cursor: items.length > 0 ? encodeCursor(hits[items.length - 1].sort) || null : null - }; - - return { - items, - meta - }; + const listForms = () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const listFormRevisions = async ( - params: FormBuilderStorageOperationsListFormRevisionsParams - ): Promise => { - const { where: initialWhere, sort } = params; - const { id, formId, tenant, locale } = initialWhere; - const queryAllParams: QueryAllParams = { - entity, - partitionKey: createFormPartitionKey({ - tenant, - locale, - id: (id || formId) as string - }), - options: { - beginsWith: "REV#" - } - }; - - let items: FbForm[] = []; - try { - items = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not query forms by given params.", - ex.code || "QUERY_FORMS_ERROR", - { - partitionKey: queryAllParams.partitionKey, - options: queryAllParams.options - } - ); - } - const where: Partial = { - ...initialWhere, - id: undefined, - formId: undefined - }; - const filteredItems = filterItems({ - plugins, - items, - where, - fields: formDynamoDbFields - }); - if (!sort || sort.length === 0) { - return filteredItems; - } - return sortItems({ - items: filteredItems, - sort, - fields: formDynamoDbFields - }); + const listFormRevisions = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const deleteForm = async ( - params: FormBuilderStorageOperationsDeleteFormParams - ): Promise => { - const { form } = params; - let items: any[]; - /** - * This will find all form and submission records. - */ - const queryAllParams = { - entity, - partitionKey: createFormPartitionKey(form), - options: { - gte: " " - } - }; - try { - items = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not query forms and submissions by given params.", - ex.code || "QUERY_FORM_AND_SUBMISSIONS_ERROR", - { - partitionKey: queryAllParams.partitionKey, - options: queryAllParams.options - } - ); - } - - const deleteItems = items.map(item => { - return entity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); - }); - try { - await batchWriteAll({ - table, - items: deleteItems - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form and it's submissions.", - ex.code || "DELETE_FORM_AND_SUBMISSIONS_ERROR" - ); - } - - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - try { - await deleteItem({ - entity: esEntity, - keys: latestKeys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete latest form record from Elasticsearch.", - ex.code || "DELETE_FORM_ERROR", - { - latestKeys - } - ); - } - return form; + const deleteForm = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - /** - * We need to: - * - delete current revision - * - get previously published revision and update the record if it exists or delete if it does not - * - update latest record if current one is the latest - */ - const deleteFormRevision = async ( - params: FormBuilderStorageOperationsDeleteFormRevisionParams - ): Promise => { - const { form, revisions, previous } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.id) - }; - - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const latestForm = revisions[0]; - const latestPublishedForm = revisions.find(rev => rev.published === true); - - const isLatest = latestForm ? latestForm.id === form.id : false; - const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - - const items = [entity.deleteBatch(revisionKeys)]; - let esDataItem = undefined; - - if (isLatest || isLatestPublished) { - /** - * Sort out the latest published record. - */ - if (isLatestPublished) { - const previouslyPublishedForm = revisions - .filter(f => !!f.publishedOn && f.version !== form.version) - .sort((a, b) => { - return ( - new Date(b.publishedOn as string).getTime() - - new Date(a.publishedOn as string).getTime() - ); - }) - .shift(); - if (previouslyPublishedForm) { - items.push( - entity.putBatch({ - ...previouslyPublishedForm, - PK: createFormPartitionKey(previouslyPublishedForm), - SK: createLatestPublishedSortKey(), - TYPE: createFormLatestPublishedType() - }) - ); - } else { - items.push( - entity.deleteBatch({ - PK: createFormPartitionKey(form), - SK: createLatestPublishedSortKey() - }) - ); - } - } - /** - * Sort out the latest record. - */ - if (isLatest && previous) { - items.push( - entity.putBatch({ - ...previous, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); - const { index } = configurations.es({ - tenant: previous.tenant, - locale: previous.locale - }); - - esDataItem = { - index, - ...latestKeys, - data: getESDataForLatestRevision(previous) - }; - } - } - /** - * Now save the batch data. - */ - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form revision from regular table.", - ex.code || "DELETE_FORM_REVISION_ERROR", - { - form, - latestForm, - revisionKeys, - latestKeys - } - ); - } - /** - * And then the Elasticsearch data, if any. - */ - if (!esDataItem) { - return form; - } - try { - await put({ - entity: esEntity, - item: esDataItem - }); - return form; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form from to the Elasticsearch table.", - ex.code || "DELETE_FORM_REVISION_ERROR", - { - form, - latestForm, - revisionKeys, - latestKeys - } - ); - } + const deleteFormRevision = async () => { + throw new Error( + "api-form-builder-so-ddb-esdoes not implement the Form Builder storage operations." + ); }; - /** - * We need to save form in: - * - regular form record - * - latest published form record - * - latest form record - if form is latest one - * - elasticsearch latest form record - */ - const publishForm = async ( - params: FormBuilderStorageOperationsPublishFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.version) - }; - - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const latestPublishedKeys = { - PK: createFormPartitionKey(form), - SK: createLatestPublishedSortKey() - }; - - const { locale, tenant, formId } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - - const isLatestForm = latestForm ? latestForm.id === form.id : false; - /** - * Update revision and latest published records - */ - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ]; - /** - * Update the latest form as well - */ - if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); - } - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not publish form.", - ex.code || "PUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } - if (!isLatestForm) { - return form; - } - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - const esData = getESDataForLatestRevision(form); - try { - await put({ - entity: esEntity, - item: { - ...latestKeys, - index, - TYPE: createFormLatestType(), - data: esData - } - }); - return form; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not publish form to the Elasticsearch.", - ex.code || "PUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } + const publishForm = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - /** - * We need to: - * - update form revision record - * - if latest published (LP) is current form, find the previously published record and update LP if there is some previously published, delete otherwise - * - if is latest update the Elasticsearch record - */ - const unpublishForm = async ( - params: FormBuilderStorageOperationsUnpublishFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form.version) - }; - - const latestKeys = { - PK: createFormPartitionKey(form), - SK: createLatestSortKey() - }; - - const latestPublishedKeys = { - PK: createFormPartitionKey(form), - SK: createLatestPublishedSortKey() - }; - - const { formId, tenant, locale } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - - const latestPublishedForm = await getForm({ - where: { - formId, - tenant, - locale, - published: true - } - }); - - const isLatest = latestForm ? latestForm.id === form.id : false; - const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }) - ]; - let esData: any = undefined; - if (isLatest) { - esData = getESDataForLatestRevision(form); - } - /** - * In case previously published revision exists, replace current one with that one. - * And if it does not, delete the record. - */ - if (isLatestPublished) { - const revisions = await listFormRevisions({ - where: { - formId, - tenant, - locale, - version_not: form.version, - publishedOn_not: null - }, - sort: ["savedOn_DESC"] - }); - - const previouslyPublishedRevision = revisions.shift(); - if (previouslyPublishedRevision) { - items.push( - entity.putBatch({ - ...previouslyPublishedRevision, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ); - } else { - items.push(entity.deleteBatch(latestPublishedKeys)); - } - } - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not unpublish form.", - ex.code || "UNPUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } - /** - * No need to go further in case of non-existing Elasticsearch data. - */ - if (!esData) { - return form; - } - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - try { - await put({ - entity: esEntity, - item: { - ...latestKeys, - index, - TYPE: createFormLatestType(), - data: esData - } - }); - return form; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not unpublish form from the Elasticsearch.", - ex.code || "UNPUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } + const unpublishForm = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; return { @@ -994,7 +71,6 @@ export const createFormStorageOperations = ( deleteForm, deleteFormRevision, publishForm, - unpublishForm, - createFormPartitionKey + unpublishForm }; }; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/formStats/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/formStats/index.ts new file mode 100644 index 00000000000..86e543d5d18 --- /dev/null +++ b/packages/api-form-builder-so-ddb-es/src/operations/formStats/index.ts @@ -0,0 +1,41 @@ +import { FormBuilderFormStatsStorageOperations } from "@webiny/api-form-builder/types"; + +export const createFormStatsStorageOperations = (): FormBuilderFormStatsStorageOperations => { + const getFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); + }; + + const listFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); + }; + + const createFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); + }; + + const updateFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); + }; + + const deleteFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); + }; + + return { + getFormStats, + listFormStats, + createFormStats, + updateFormStats, + deleteFormStats + }; +}; diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts index a1e1a00aafa..1492e40121d 100644 --- a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts @@ -1,374 +1,34 @@ -import { - FbSubmission, - FormBuilderStorageOperationsCreateSubmissionParams, - FormBuilderStorageOperationsDeleteSubmissionParams, - FormBuilderStorageOperationsGetSubmissionParams, - FormBuilderStorageOperationsListSubmissionsParams, - FormBuilderStorageOperationsListSubmissionsResponse, - FormBuilderStorageOperationsUpdateSubmissionParams -} from "@webiny/api-form-builder/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { Client } from "@elastic/elasticsearch"; -import WebinyError from "@webiny/error"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { createLimit, decodeCursor, encodeCursor } from "@webiny/api-elasticsearch"; -import { - createElasticsearchBody, - createSubmissionElasticType -} from "~/operations/submission/elasticsearchBody"; -import { PluginsContainer } from "@webiny/plugins"; -import { - FormBuilderSubmissionStorageOperations, - FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams -} from "~/types"; -import { configurations } from "~/configurations"; -import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; -import { parseIdentifier } from "@webiny/utils"; -import { ElasticsearchSearchResponse } from "@webiny/api-elasticsearch/types"; -import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; +import { FormBuilderSubmissionStorageOperations } from "@webiny/api-form-builder/types"; -export interface CreateSubmissionStorageOperationsParams { - entity: Entity; - esEntity: Entity; - table: Table; - elasticsearch: Client; - plugins: PluginsContainer; -} - -export const createSubmissionStorageOperations = ( - params: CreateSubmissionStorageOperationsParams -): FormBuilderSubmissionStorageOperations => { - const { entity, esEntity, table, elasticsearch, plugins } = params; - - const createSubmissionPartitionKey = ( - params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams - ) => { - const { tenant, locale, formId } = params; - - const { id } = parseIdentifier(formId); - - return `T#${tenant}#L#${locale}#FB#F#${id}`; - }; - const createSubmissionSortKey = (id: string) => { - return `FS#${id}`; - }; - - const createSubmissionType = () => { - return "fb.formSubmission"; - }; - - const createSubmission = async ( - params: FormBuilderStorageOperationsCreateSubmissionParams - ): Promise => { - const { submission, form } = params; - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await put({ - entity, - item: { - ...submission, - ...keys, - TYPE: createSubmissionType() - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create form submission in the DynamoDB.", - ex.code || "UPDATE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - try { - const { index } = configurations.es({ - tenant: form.tenant, - locale: form.locale - }); - await put({ - entity: esEntity, - item: { - index, - data: { - ...submission, - __type: createSubmissionElasticType() - }, - TYPE: createSubmissionType(), - ...keys - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create form submission in the Elasticsearch.", - ex.code || "UPDATE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - return submission; - }; - /** - * We do not save the data in the Elasticsearch because there is no need for that. - */ - const updateSubmission = async ( - params: FormBuilderStorageOperationsUpdateSubmissionParams - ): Promise => { - const { submission, form, original } = params; - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await put({ - entity, - item: { - ...submission, - ...keys, - TYPE: createSubmissionType() - } - }); - return submission; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update form submission in the DynamoDB.", - ex.code || "UPDATE_FORM_SUBMISSION_ERROR", - { - submission, - original, - form, - keys - } - ); - } - }; - - const deleteSubmission = async ( - params: FormBuilderStorageOperationsDeleteSubmissionParams - ): Promise => { - const { submission, form } = params; - - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await deleteItem({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form submission from DynamoDB.", - ex.code || "DELETE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - try { - await deleteItem({ - entity: esEntity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form submission from Elasticsearch.", - ex.code || "DELETE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - return submission; +export const createSubmissionStorageOperations = (): FormBuilderSubmissionStorageOperations => { + const createSubmission = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - /** - * - * We are using this method because it is faster to fetch the exact data from the DynamoDB than Elasticsearch. - * - * @internal - */ - const listSubmissionsByIds = async ( - params: FormBuilderStorageOperationsListSubmissionsParams - ): Promise => { - const { where, sort } = params; - const items = (where.id_in || []).map(id => { - return entity.getBatch({ - PK: createSubmissionPartitionKey({ - ...where - }), - SK: createSubmissionSortKey(id) - }); - }); - - let results: FbSubmission[] = []; - - try { - results = await batchReadAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not batch read form submissions.", - ex.code || "BATCH_READ_SUBMISSIONS_ERROR", - { - where, - sort - } - ); - } - /** - * We need to remove empty results because it is a possibility that batch read returned null for non-existing record. - */ - const submissions = results.filter(Boolean).map(submission => { - return cleanupItem(entity, submission); - }) as FbSubmission[]; - if (!sort) { - return submissions; - } - return sortItems({ - items: submissions, - sort, - fields: [] - }); + const updateSubmission = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const listSubmissions = async ( - params: FormBuilderStorageOperationsListSubmissionsParams - ): Promise => { - const { where, sort = [], limit: initialLimit, after } = params; - - if (where.id_in) { - const items = await listSubmissionsByIds(params); - - return { - items, - meta: { - hasMoreItems: false, - cursor: null, - totalCount: items.length - } - }; - } - - const limit = createLimit(initialLimit); - - const body = createElasticsearchBody({ - plugins, - sort, - limit: limit + 1, - where, - after: decodeCursor(after) - }); - - const esConfig = configurations.es({ - tenant: where.tenant, - locale: where.locale - }); - - const query = { - ...esConfig, - body - }; - - let response: ElasticsearchSearchResponse; - try { - response = await elasticsearch.search(query); - } catch (ex) { - throw new WebinyError( - ex.message || "Could list form submissions.", - ex.code || "LIST_SUBMISSIONS_ERROR", - { - where, - query - } - ); - } - - const { hits, total } = response.body.hits; - const items = hits.map(item => item._source); - - const hasMoreItems = items.length > limit; - if (hasMoreItems) { - /** - * Remove the last item from results, we don't want to include it. - */ - items.pop(); - } - /** - * Cursor is the `sort` value of the last item in the array. - * https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#search-after - */ - const meta = { - hasMoreItems, - totalCount: total.value, - cursor: items.length > 0 ? encodeCursor(hits[items.length - 1].sort) || null : null - }; - - return { - items, - meta - }; + const deleteSubmission = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; - const getSubmission = async ( - params: FormBuilderStorageOperationsGetSubmissionParams - ): Promise => { - const { where } = params; - - const keys = { - PK: createSubmissionPartitionKey({ - ...where, - formId: where.formId as string - }), - SK: createSubmissionSortKey(where.id) - }; - - try { - return await getClean({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not oad submission.", - ex.code || "GET_SUBMISSION_ERROR", - { - where, - keys - } - ); - } + const listSubmissions = async () => { + throw new Error( + "api-form-builder-so-ddb-es does not implement the Form Builder storage operations." + ); }; return { createSubmission, deleteSubmission, updateSubmission, - listSubmissions, - getSubmission, - createSubmissionPartitionKey, - createSubmissionSortKey + listSubmissions }; }; diff --git a/packages/api-form-builder-so-ddb-es/src/types.ts b/packages/api-form-builder-so-ddb-es/src/types.ts index a86b732de5d..8fd26c43fa6 100644 --- a/packages/api-form-builder-so-ddb-es/src/types.ts +++ b/packages/api-form-builder-so-ddb-es/src/types.ts @@ -1,26 +1,21 @@ import { FormBuilderStorageOperations as BaseFormBuilderStorageOperations, FormBuilderSystemStorageOperations as BaseFormBuilderSystemStorageOperations, - FormBuilderSubmissionStorageOperations as BaseFormBuilderSubmissionStorageOperations, + FormBuilderSubmissionStorageOperations, FormBuilderSettingsStorageOperations as BaseFormBuilderSettingsStorageOperations, - FormBuilderFormStorageOperations as BaseFormBuilderFormStorageOperations, + FormBuilderFormStorageOperations, FormBuilderContext } from "@webiny/api-form-builder/types"; import { DynamoDBClient } from "@webiny/aws-sdk/client-dynamodb"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; +import { Entity } from "@webiny/db-dynamodb/toolbox"; import { AttributeDefinition } from "@webiny/db-dynamodb/toolbox"; import { Client } from "@elastic/elasticsearch"; -import { PluginCollection } from "@webiny/plugins/types"; - -export { FormBuilderContext }; export type Attributes = Record; +export { FormBuilderContext }; + export enum ENTITIES { - FORM = "FormBuilderForm", - ES_FORM = "FormBuilderFormEs", - SUBMISSION = "FormBuilderSubmission", - ES_SUBMISSION = "FormBuilderSubmissionEs", SYSTEM = "FormBuilderSystem", SETTINGS = "FormBuilderSettings" } @@ -31,7 +26,6 @@ export interface FormBuilderStorageOperationsFactoryParams { table?: string; esTable?: string; attributes?: Record; - plugins?: PluginCollection; } export interface FormBuilderSystemCreateKeysParams { @@ -48,25 +42,12 @@ export interface FormBuilderFormCreateKeyParams { tenant: string; locale: string; } - -export interface FormBuilderFormStorageOperations extends BaseFormBuilderFormStorageOperations { - createFormPartitionKey: (params: FormBuilderFormCreateKeyParams) => string; -} - export interface FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams { tenant: string; locale: string; formId: string; } -export interface FormBuilderSubmissionStorageOperations - extends BaseFormBuilderSubmissionStorageOperations { - createSubmissionPartitionKey: ( - params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams - ) => string; - createSubmissionSortKey: (id: string) => string; -} - export interface FormBuilderSettingsStorageOperationsCreatePartitionKeyParams { tenant: string; locale: string; @@ -80,16 +61,14 @@ export interface FormBuilderSettingsStorageOperations createSettingsSortKey: () => string; } -export type Entities = "form" | "esForm" | "submission" | "esSubmission" | "system" | "settings"; +export type Entities = "system" | "settings"; export interface FormBuilderStorageOperations extends BaseFormBuilderStorageOperations, FormBuilderSettingsStorageOperations, - FormBuilderSubmissionStorageOperations, - FormBuilderFormStorageOperations, FormBuilderSystemStorageOperations { - getTable(): Table; - getEsTable(): Table; + forms: FormBuilderFormStorageOperations; + submissions: FormBuilderSubmissionStorageOperations; getEntities(): Record>; } diff --git a/packages/api-form-builder-so-ddb-es/tsconfig.build.json b/packages/api-form-builder-so-ddb-es/tsconfig.build.json index 2ee283960f0..e92abfe786d 100644 --- a/packages/api-form-builder-so-ddb-es/tsconfig.build.json +++ b/packages/api-form-builder-so-ddb-es/tsconfig.build.json @@ -9,7 +9,6 @@ { "path": "../db-dynamodb/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, - { "path": "../utils/tsconfig.build.json" }, { "path": "../api-dynamodb-to-elasticsearch/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-db/tsconfig.build.json" } diff --git a/packages/api-form-builder-so-ddb-es/tsconfig.json b/packages/api-form-builder-so-ddb-es/tsconfig.json index 741d425b0c9..5cfc7fee06c 100644 --- a/packages/api-form-builder-so-ddb-es/tsconfig.json +++ b/packages/api-form-builder-so-ddb-es/tsconfig.json @@ -9,7 +9,6 @@ { "path": "../db-dynamodb" }, { "path": "../error" }, { "path": "../plugins" }, - { "path": "../utils" }, { "path": "../api-dynamodb-to-elasticsearch" }, { "path": "../handler-aws" }, { "path": "../handler-db" } @@ -35,8 +34,6 @@ "@webiny/error": ["../error/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/utils/*": ["../utils/src/*"], - "@webiny/utils": ["../utils/src"], "@webiny/api-dynamodb-to-elasticsearch/*": ["../api-dynamodb-to-elasticsearch/src/*"], "@webiny/api-dynamodb-to-elasticsearch": ["../api-dynamodb-to-elasticsearch/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-form-builder-so-ddb/package.json b/packages/api-form-builder-so-ddb/package.json index cb68ca23a21..717278fd395 100644 --- a/packages/api-form-builder-so-ddb/package.json +++ b/packages/api-form-builder-so-ddb/package.json @@ -25,9 +25,7 @@ "@webiny/api-form-builder": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/db-dynamodb": "0.0.0", - "@webiny/error": "0.0.0", - "@webiny/plugins": "0.0.0", - "@webiny/utils": "0.0.0" + "@webiny/error": "0.0.0" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-form-builder-so-ddb/src/definitions/form.ts b/packages/api-form-builder-so-ddb/src/definitions/form.ts index 400e93a26b4..17d3b21f2b8 100644 --- a/packages/api-form-builder-so-ddb/src/definitions/form.ts +++ b/packages/api-form-builder-so-ddb/src/definitions/form.ts @@ -79,9 +79,6 @@ export const createFormEntity = (params: Params): Entity => { steps: { type: "list" }, - stats: { - type: "map" - }, settings: { type: "map" }, diff --git a/packages/api-form-builder-so-ddb/src/index.ts b/packages/api-form-builder-so-ddb/src/index.ts index 1f530b61e49..61fbf108ad3 100644 --- a/packages/api-form-builder-so-ddb/src/index.ts +++ b/packages/api-form-builder-so-ddb/src/index.ts @@ -1,19 +1,13 @@ -import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters"; -import formSubmissionFields from "~/operations/submission/fields"; -import formFields from "~/operations/form/fields"; import WebinyError from "@webiny/error"; import { FormBuilderStorageOperationsFactory, ENTITIES } from "~/types"; import { createTable } from "~/definitions/table"; -import { createFormEntity } from "~/definitions/form"; -import { createSubmissionEntity } from "~/definitions/submission"; import { createSystemEntity } from "~/definitions/system"; import { createSettingsEntity } from "~/definitions/settings"; import { createSystemStorageOperations } from "~/operations/system"; import { createSubmissionStorageOperations } from "~/operations/submission"; import { createSettingsStorageOperations } from "~/operations/settings"; import { createFormStorageOperations } from "~/operations/form"; -import { PluginsContainer } from "@webiny/plugins"; -import { FormDynamoDbFieldPlugin, FormSubmissionDynamoDbFieldPlugin } from "~/plugins"; +import { createFormStatsStorageOperations } from "~/operations/formStats"; const reservedFields = ["PK", "SK", "index", "data", "TYPE", "__type", "GSI1_PK", "GSI1_SK"]; @@ -29,7 +23,7 @@ const isReserved = (name: string): void => { export * from "./plugins"; export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFactory = params => { - const { attributes, table: tableName, documentClient, plugins: userPlugins } = params; + const { attributes, table: tableName, documentClient } = params; if (attributes) { Object.values(attributes).forEach(attrs => { @@ -37,26 +31,6 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac }); } - const plugins = new PluginsContainer([ - /** - * User defined plugins. - */ - userPlugins || [], - /** - * Form submission DynamoDB fields. - */ - formSubmissionFields(), - /** - * Form DynamoDB fields. - */ - formFields(), - - /** - * DynamoDB filter plugins for the where conditions. - */ - dynamoDbValueFilters() - ]); - const table = createTable({ tableName, documentClient @@ -66,16 +40,6 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac /** * Regular entities. */ - form: createFormEntity({ - entityName: ENTITIES.FORM, - table, - attributes: attributes ? attributes[ENTITIES.FORM] : {} - }), - submission: createSubmissionEntity({ - entityName: ENTITIES.SUBMISSION, - table, - attributes: attributes ? attributes[ENTITIES.SUBMISSION] : {} - }), system: createSystemEntity({ entityName: ENTITIES.SYSTEM, table, @@ -89,15 +53,6 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac }; return { - beforeInit: async context => { - const types: string[] = [ - FormDynamoDbFieldPlugin.type, - FormSubmissionDynamoDbFieldPlugin.type - ]; - for (const type of types) { - plugins.mergeByType(context.plugins, type); - } - }, getTable: () => table, getEntities: () => entities, ...createSystemStorageOperations({ @@ -108,15 +63,8 @@ export const createFormBuilderStorageOperations: FormBuilderStorageOperationsFac table, entity: entities.settings }), - ...createFormStorageOperations({ - table, - entity: entities.form, - plugins - }), - ...createSubmissionStorageOperations({ - table, - entity: entities.submission, - plugins - }) + forms: createFormStorageOperations(), + formStats: createFormStatsStorageOperations(), + submissions: createSubmissionStorageOperations() }; }; diff --git a/packages/api-form-builder-so-ddb/src/operations/form/fields.ts b/packages/api-form-builder-so-ddb/src/operations/form/fields.ts deleted file mode 100644 index d3ab95a7b4e..00000000000 --- a/packages/api-form-builder-so-ddb/src/operations/form/fields.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FormDynamoDbFieldPlugin } from "~/plugins/FormDynamoDbFieldPlugin"; - -export default () => [ - new FormDynamoDbFieldPlugin({ - field: "publishedOn", - type: "date" - }), - new FormDynamoDbFieldPlugin({ - field: "createdOn", - type: "date" - }), - new FormDynamoDbFieldPlugin({ - field: "savedOn", - type: "date" - }), - new FormDynamoDbFieldPlugin({ - field: "name" - }), - new FormDynamoDbFieldPlugin({ - field: "slug" - }), - new FormDynamoDbFieldPlugin({ - field: "locale" - }), - new FormDynamoDbFieldPlugin({ - field: "tenant" - }), - new FormDynamoDbFieldPlugin({ - field: "published", - type: "boolean" - }), - new FormDynamoDbFieldPlugin({ - field: "status" - }), - new FormDynamoDbFieldPlugin({ - field: "version", - type: "number" - }), - new FormDynamoDbFieldPlugin({ - field: "ownedBy", - path: "ownedBy.id" - }), - new FormDynamoDbFieldPlugin({ - field: "createdBy", - path: "createdBy.id" - }) -]; diff --git a/packages/api-form-builder-so-ddb/src/operations/form/index.ts b/packages/api-form-builder-so-ddb/src/operations/form/index.ts index 3ade575baa2..25b8bac04a2 100644 --- a/packages/api-form-builder-so-ddb/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb/src/operations/form/index.ts @@ -1,825 +1,64 @@ -import WebinyError from "@webiny/error"; -import { - FbForm, - FormBuilderStorageOperationsCreateFormFromParams, - FormBuilderStorageOperationsCreateFormParams, - FormBuilderStorageOperationsDeleteFormParams, - FormBuilderStorageOperationsDeleteFormRevisionParams, - FormBuilderStorageOperationsGetFormParams, - FormBuilderStorageOperationsListFormRevisionsParams, - FormBuilderStorageOperationsListFormRevisionsParamsWhere, - FormBuilderStorageOperationsListFormsParams, - FormBuilderStorageOperationsListFormsResponse, - FormBuilderStorageOperationsPublishFormParams, - FormBuilderStorageOperationsUnpublishFormParams, - FormBuilderStorageOperationsUpdateFormParams -} from "@webiny/api-form-builder/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { filterItems } from "@webiny/db-dynamodb/utils/filter"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { createIdentifier, parseIdentifier } from "@webiny/utils"; -import { PluginsContainer } from "@webiny/plugins"; -import { - FormBuilderFormCreateGSIPartitionKeyParams, - FormBuilderFormCreatePartitionKeyParams, - FormBuilderFormStorageOperations -} from "~/types"; -import { FormDynamoDbFieldPlugin } from "~/plugins/FormDynamoDbFieldPlugin"; -import { decodeCursor, encodeCursor } from "@webiny/db-dynamodb/utils/cursor"; -import { getClean } from "@webiny/db-dynamodb/utils/get"; +import { FormBuilderFormStorageOperations } from "@webiny/api-form-builder/types"; -type DbRecord = T & { - PK: string; - SK: string; - TYPE: string; -}; - -interface Keys { - PK: string; - SK: string; -} - -interface FormLatestSortKeyParams { - id?: string; - formId?: string; -} - -interface GsiKeys { - GSI1_PK: string; - GSI1_SK: string; -} - -export interface CreateFormStorageOperationsParams { - entity: Entity; - table: Table; - plugins: PluginsContainer; -} - -export const createFormStorageOperations = ( - params: CreateFormStorageOperationsParams -): FormBuilderFormStorageOperations => { - const { entity, table, plugins } = params; - - const formDynamoDbFields = plugins.byType( - FormDynamoDbFieldPlugin.type - ); - - const createFormPartitionKey = (params: FormBuilderFormCreatePartitionKeyParams): string => { - const { tenant, locale } = params; - - return `T#${tenant}#L#${locale}#FB#F`; +export const createFormStorageOperations = (): FormBuilderFormStorageOperations => { + const createForm = () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const createFormLatestPartitionKey = ( - params: FormBuilderFormCreatePartitionKeyParams - ): string => { - return `${createFormPartitionKey(params)}#L`; - }; - - const createFormLatestPublishedPartitionKey = ( - params: FormBuilderFormCreatePartitionKeyParams - ): string => { - return `${createFormPartitionKey(params)}#LP`; - }; - - const createFormGSIPartitionKey = ( - params: FormBuilderFormCreateGSIPartitionKeyParams - ): string => { - const { tenant, locale, id: targetId } = params; - const { id } = parseIdentifier(targetId); - - return `T#${tenant}#L#${locale}#FB#F#${id}`; - }; - - const createRevisionSortKey = ({ id }: { id: string }): string => { - return `${id}`; - }; - - const createFormLatestSortKey = ({ id, formId }: FormLatestSortKeyParams): string => { - const value = parseIdentifier(id || formId); - return value.id; - }; - - const createLatestPublishedSortKey = ({ id, formId }: FormLatestSortKeyParams): string => { - const value = parseIdentifier(id || formId); - return value.id; - }; - - const createGSISortKey = (version: number) => { - return `${version}`; - }; - - const createFormType = (): string => { - return "fb.form"; - }; - - const createFormLatestType = (): string => { - return "fb.form.latest"; - }; - - const createFormLatestPublishedType = (): string => { - return "fb.form.latestPublished"; - }; - - const createRevisionKeys = (form: FbForm): Keys => { - return { - PK: createFormPartitionKey(form), - SK: createRevisionSortKey(form) - }; - }; - - const createLatestKeys = (form: FbForm): Keys => { - return { - PK: createFormLatestPartitionKey(form), - SK: createFormLatestSortKey(form) - }; - }; - - const createLatestPublishedKeys = (form: FbForm): Keys => { - return { - PK: createFormLatestPublishedPartitionKey(form), - SK: createLatestPublishedSortKey(form) - }; - }; - - const createGSIKeys = (form: FbForm): GsiKeys => { - return { - GSI1_PK: createFormGSIPartitionKey(form), - GSI1_SK: createGSISortKey(form.version) - }; - }; - - const createForm = async ( - params: FormBuilderStorageOperationsCreateFormParams - ): Promise => { - const { form } = params; - - const revisionKeys = createRevisionKeys(form); - const latestKeys = createLatestKeys(form); - const gsiKeys = createGSIKeys(form); - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not insert form data into table.", - ex.code || "CREATE_FORM_ERROR", - { - revisionKeys, - latestKeys, - form - } - ); - } - return form; - }; - - const createFormFrom = async ( - params: FormBuilderStorageOperationsCreateFormFromParams - ): Promise => { - const { form, original, latest } = params; - - const revisionKeys = createRevisionKeys(form); - const latestKeys = createLatestKeys(form); - const gsiKeys = createGSIKeys(form); - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create form data in the table, from existing form.", - ex.code || "CREATE_FORM_FROM_ERROR", - { - revisionKeys, - latestKeys, - original, - form, - latest - } - ); - } - - return form; + const createFormFrom = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const updateForm = async ( - params: FormBuilderStorageOperationsUpdateFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = createRevisionKeys(form); - const latestKeys = createLatestKeys(form); - const gsiKeys = createGSIKeys(form); - - const { formId, tenant, locale } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - const isLatestForm = latestForm ? latestForm.id === form.id : false; - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }) - ]; - if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); - } - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update form data in the table.", - ex.code || "UPDATE_FORM_ERROR", - { - revisionKeys, - latestKeys, - original, - form, - latestForm - } - ); - } - - return form; + const updateForm = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const getForm = async ( - params: FormBuilderStorageOperationsGetFormParams - ): Promise => { - const { where } = params; - const { id, formId, latest, published, version } = where; - if (latest && published) { - throw new WebinyError("Cannot have both latest and published params."); - } - let partitionKey: string; - let sortKey: string; - if (latest) { - partitionKey = createFormLatestPartitionKey(where); - sortKey = createFormLatestSortKey(where); - } else if (published && !version) { - /** - * Because of the specifics how DynamoDB works, we must not load the published record if version is sent. - */ - partitionKey = createFormLatestPublishedPartitionKey(where); - sortKey = createLatestPublishedSortKey(where); - } else if (id || version) { - partitionKey = createFormPartitionKey(where); - sortKey = createRevisionSortKey({ - id: - id || - createIdentifier({ - id: formId as string, - version: version as number - }) - }); - } else { - throw new WebinyError( - "Missing parameter to create a sort key.", - "MISSING_WHERE_PARAMETER", - { - where - } - ); - } - - const keys = { - PK: partitionKey, - SK: sortKey - }; - - try { - return await getClean({ entity, keys }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not get form by keys.", - ex.code || "GET_FORM_ERROR", - { - keys - } - ); - } + const getForm = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const listForms = async ( - params: FormBuilderStorageOperationsListFormsParams - ): Promise => { - const { sort, limit, where: initialWhere, after } = params; - - const queryAllParams: QueryAllParams = { - entity, - partitionKey: createFormLatestPartitionKey(initialWhere), - options: { - gte: " " - } - }; - - let results; - try { - results = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could list forms.", - ex.code || "LIST_FORMS_ERROR", - { - where: initialWhere, - partitionKey: queryAllParams.partitionKey - } - ); - } - const totalCount = results.length; - - const where: Partial = { - ...initialWhere - }; - /** - * We need to remove conditions so we do not filter by them again. - */ - delete where.tenant; - delete where.locale; - - const filteredItems = filterItems({ - plugins, - items: results, - where, - fields: formDynamoDbFields - }); - - const sortedItems = sortItems({ - items: filteredItems, - sort, - fields: formDynamoDbFields - }); - - const start = parseInt(decodeCursor(after) || "0") || 0; - const hasMoreItems = totalCount > start + limit; - const end = limit > totalCount + start + limit ? undefined : start + limit; - const items = sortedItems.slice(start, end); - /** - * Although we do not need a cursor here, we will use it as such to keep it standardized. - * Number is simply encoded. - */ - const cursor = items.length > 0 ? encodeCursor(start + limit) : null; - - const meta = { - hasMoreItems, - totalCount, - cursor - }; - - return { - items, - meta - }; + const listForms = () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const listFormRevisions = async ( - params: FormBuilderStorageOperationsListFormRevisionsParams - ): Promise => { - const { where: initialWhere, sort } = params; - const { id, formId, tenant, locale } = initialWhere; - - const queryAllParams: QueryAllParams = { - entity, - partitionKey: createFormGSIPartitionKey({ - tenant, - locale, - id: formId || id - }), - options: { - index: "GSI1", - gte: " " - } - }; - - let items: FbForm[] = []; - try { - items = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not query forms by given params.", - ex.code || "QUERY_FORMS_ERROR", - { - partitionKey: queryAllParams.partitionKey, - options: queryAllParams.options - } - ); - } - const where: Partial = { - ...initialWhere - }; - /** - * We need to remove conditions so we do not filter by them again. - */ - delete where.id; - delete where.formId; - delete where.tenant; - delete where.locale; - - const filteredItems = filterItems({ - plugins, - items, - where, - fields: formDynamoDbFields - }); - return sortItems({ - items: filteredItems, - sort, - fields: formDynamoDbFields - }); + const listFormRevisions = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const deleteForm = async ( - params: FormBuilderStorageOperationsDeleteFormParams - ): Promise => { - const { form } = params; - let items: any[]; - /** - * This will find all form records. - */ - const queryAllParams: QueryAllParams = { - entity, - partitionKey: createFormPartitionKey(form), - options: { - beginsWith: form.formId || undefined - } - }; - try { - items = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not query forms and submissions by given params.", - ex.code || "QUERY_FORM_AND_SUBMISSIONS_ERROR", - { - partitionKey: queryAllParams.partitionKey, - options: queryAllParams.options - } - ); - } - - let hasLatestPublishedRecord = false; - - const deleteItems = items.map(item => { - if (!hasLatestPublishedRecord && item.published) { - hasLatestPublishedRecord = true; - } - return entity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); - }); - if (hasLatestPublishedRecord) { - deleteItems.push(entity.deleteBatch(createLatestPublishedKeys(items[0]))); - } - - deleteItems.push(entity.deleteBatch(createLatestKeys(items[0]))); - - try { - await batchWriteAll({ - table, - items: deleteItems - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form and it's submissions.", - ex.code || "DELETE_FORM_AND_SUBMISSIONS_ERROR" - ); - } - return form; + const deleteForm = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - /** - * We need to: - * - delete current revision - * - get previously published revision and update the record if it exists or delete if it does not - * - update latest record if current one is the latest - */ - const deleteFormRevision = async ( - params: FormBuilderStorageOperationsDeleteFormRevisionParams - ): Promise => { - const { form, revisions, previous } = params; - - const revisionKeys = createRevisionKeys(form); - const latestKeys = createLatestKeys(form); - - const latestForm = revisions[0]; - const latestPublishedForm = revisions.find(rev => rev.published === true); - const isLatest = latestForm ? latestForm.id === form.id : false; - const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - - const items = [entity.deleteBatch(revisionKeys)]; - - if (isLatest || isLatestPublished) { - /** - * Sort out the latest published record. - */ - if (isLatestPublished) { - const previouslyPublishedForm = revisions - .filter(f => !!f.publishedOn && f.version !== form.version) - .sort((a, b) => { - return ( - new Date(b.publishedOn as string).getTime() - - new Date(a.publishedOn as string).getTime() - ); - }) - .shift(); - if (previouslyPublishedForm) { - items.push( - entity.putBatch({ - ...previouslyPublishedForm, - ...createLatestPublishedKeys(previouslyPublishedForm), - GSI1_PK: null, - GSI1_SK: null, - TYPE: createFormLatestPublishedType() - }) - ); - } else { - items.push(entity.deleteBatch(createLatestPublishedKeys(form))); - } - } - /** - * Sort out the latest record. - */ - if (isLatest) { - items.push( - entity.putBatch({ - ...previous, - ...latestKeys, - GSI1_PK: null, - GSI1_SK: null, - TYPE: createFormLatestType() - }) - ); - } - } - /** - * Now save the batch data. - */ - try { - await batchWriteAll({ - table, - items - }); - return form; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form revision from table.", - ex.code || "DELETE_FORM_REVISION_ERROR", - { - form, - latestForm, - revisionKeys, - latestKeys - } - ); - } + const deleteFormRevision = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - /** - * We need to save form in: - * - regular form record - * - latest published form record - * - latest form record - if form is latest one - * - elasticsearch latest form record - */ - const publishForm = async ( - params: FormBuilderStorageOperationsPublishFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = createRevisionKeys(form); - - const latestKeys = createLatestKeys(form); - - const latestPublishedKeys = createLatestPublishedKeys(form); - - const gsiKeys = { - GSI1_PK: createFormGSIPartitionKey(form), - GSI1_SK: createGSISortKey(form.version) - }; - - const { locale, tenant, formId } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - - const isLatestForm = latestForm ? latestForm.id === form.id : false; - /** - * Update revision and latest published records - */ - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ]; - /** - * Update the latest form as well - */ - if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); - } - - try { - await batchWriteAll({ - table, - items - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not publish form.", - ex.code || "PUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } - return form; + const publishForm = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - /** - * We need to: - * - update form revision record - * - if latest published (LP) is current form, find the previously published record and update LP if there is some previously published, delete otherwise - * - if is latest update the Elasticsearch record - */ - const unpublishForm = async ( - params: FormBuilderStorageOperationsUnpublishFormParams - ): Promise => { - const { form, original } = params; - - const revisionKeys = createRevisionKeys(form); - const latestKeys = createLatestKeys(form); - const latestPublishedKeys = createLatestPublishedKeys(form); - const gsiKeys = createGSIKeys(form); - - const { formId, tenant, locale } = form; - - const latestForm = await getForm({ - where: { - formId, - tenant, - locale, - latest: true - } - }); - - const latestPublishedForm = await getForm({ - where: { - formId, - tenant, - locale, - published: true - } - }); - - const isLatest = latestForm ? latestForm.id === form.id : false; - const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }) - ]; - - if (isLatest) { - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }); - } - /** - * In case previously published revision exists, replace current one with that one. - * And if it does not, delete the record. - */ - if (isLatestPublished) { - const revisions = await listFormRevisions({ - where: { - formId, - tenant, - locale, - version_not: form.version, - publishedOn_not: null - }, - sort: ["savedOn_DESC"] - }); - - const previouslyPublishedRevision = revisions.shift(); - if (previouslyPublishedRevision) { - items.push( - entity.putBatch({ - ...previouslyPublishedRevision, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ); - } else { - items.push(entity.deleteBatch(latestPublishedKeys)); - } - } - - try { - await batchWriteAll({ - table, - items - }); - return form; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not unpublish form.", - ex.code || "UNPUBLISH_FORM_ERROR", - { - form, - original, - latestForm, - revisionKeys, - latestKeys, - latestPublishedKeys - } - ); - } + const unpublishForm = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; return { @@ -832,7 +71,6 @@ export const createFormStorageOperations = ( deleteForm, deleteFormRevision, publishForm, - unpublishForm, - createFormPartitionKey + unpublishForm }; }; diff --git a/packages/api-form-builder-so-ddb/src/operations/formStats/index.ts b/packages/api-form-builder-so-ddb/src/operations/formStats/index.ts new file mode 100644 index 00000000000..d57b5383821 --- /dev/null +++ b/packages/api-form-builder-so-ddb/src/operations/formStats/index.ts @@ -0,0 +1,41 @@ +import { FormBuilderFormStatsStorageOperations } from "@webiny/api-form-builder/types"; + +export const createFormStatsStorageOperations = (): FormBuilderFormStatsStorageOperations => { + const getFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); + }; + + const listFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); + }; + + const createFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); + }; + + const updateFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); + }; + + const deleteFormStats = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); + }; + + return { + getFormStats, + listFormStats, + createFormStats, + updateFormStats, + deleteFormStats + }; +}; diff --git a/packages/api-form-builder-so-ddb/src/operations/submission/fields.ts b/packages/api-form-builder-so-ddb/src/operations/submission/fields.ts deleted file mode 100644 index 48aa15e615e..00000000000 --- a/packages/api-form-builder-so-ddb/src/operations/submission/fields.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { FormSubmissionDynamoDbFieldPlugin } from "~/plugins/FormSubmissionDynamoDbFieldPlugin"; - -export default () => [ - new FormSubmissionDynamoDbFieldPlugin({ - field: "createdOn", - type: "date" - }), - new FormSubmissionDynamoDbFieldPlugin({ - field: "savedOn", - type: "date" - }) -]; diff --git a/packages/api-form-builder-so-ddb/src/operations/submission/index.ts b/packages/api-form-builder-so-ddb/src/operations/submission/index.ts index aa242d9cb25..5923b70850a 100644 --- a/packages/api-form-builder-so-ddb/src/operations/submission/index.ts +++ b/packages/api-form-builder-so-ddb/src/operations/submission/index.ts @@ -1,267 +1,34 @@ -import { - FbSubmission, - FormBuilderStorageOperationsCreateSubmissionParams, - FormBuilderStorageOperationsDeleteSubmissionParams, - FormBuilderStorageOperationsGetSubmissionParams, - FormBuilderStorageOperationsListSubmissionsParams, - FormBuilderStorageOperationsListSubmissionsResponse, - FormBuilderStorageOperationsUpdateSubmissionParams -} from "@webiny/api-form-builder/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import WebinyError from "@webiny/error"; -import { PluginsContainer } from "@webiny/plugins"; -import { - FormBuilderSubmissionStorageOperations, - FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams -} from "~/types"; -import { parseIdentifier } from "@webiny/utils"; -import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { decodeCursor, encodeCursor } from "@webiny/db-dynamodb/utils/cursor"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { filterItems } from "@webiny/db-dynamodb/utils/filter"; -import { FormSubmissionDynamoDbFieldPlugin } from "~/plugins/FormSubmissionDynamoDbFieldPlugin"; -import { getClean } from "@webiny/db-dynamodb/utils/get"; -import { deleteItem, put } from "@webiny/db-dynamodb"; +import { FormBuilderSubmissionStorageOperations } from "@webiny/api-form-builder/types"; -export interface CreateSubmissionStorageOperationsParams { - entity: Entity; - table: Table; - plugins: PluginsContainer; -} - -export const createSubmissionStorageOperations = ( - params: CreateSubmissionStorageOperationsParams -): FormBuilderSubmissionStorageOperations => { - const { entity, plugins } = params; - - const createSubmissionPartitionKey = ( - params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams - ) => { - const { tenant, locale, formId } = params; - - const { id } = parseIdentifier(formId); - - return `T#${tenant}#L#${locale}#FB#FS#${id}`; - }; - const createSubmissionSortKey = (id: string) => { - return id; - }; - - const createSubmissionType = () => { - return "fb.formSubmission"; - }; - - const createSubmission = async ( - params: FormBuilderStorageOperationsCreateSubmissionParams - ): Promise => { - const { submission, form } = params; - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await put({ - entity, - item: { - ...submission, - ...keys, - TYPE: createSubmissionType() - } - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create form submission in the DynamoDB.", - ex.code || "UPDATE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - return submission; - }; - - const updateSubmission = async ( - params: FormBuilderStorageOperationsUpdateSubmissionParams - ): Promise => { - const { submission, form, original } = params; - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await put({ - entity, - item: { - ...submission, - ...keys, - TYPE: createSubmissionType() - } - }); - return submission; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update form submission in the DynamoDB.", - ex.code || "UPDATE_FORM_SUBMISSION_ERROR", - { - submission, - original, - form, - keys - } - ); - } +export const createSubmissionStorageOperations = (): FormBuilderSubmissionStorageOperations => { + const createSubmission = () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const deleteSubmission = async ( - params: FormBuilderStorageOperationsDeleteSubmissionParams - ): Promise => { - const { submission, form } = params; - - const keys = { - PK: createSubmissionPartitionKey(form), - SK: createSubmissionSortKey(submission.id) - }; - - try { - await deleteItem({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete form submission from DynamoDB.", - ex.code || "DELETE_FORM_SUBMISSION_ERROR", - { - submission, - form, - keys - } - ); - } - - return submission; + const updateSubmission = () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; - const listSubmissions = async ( - params: FormBuilderStorageOperationsListSubmissionsParams - ): Promise => { - const { where: initialWhere, sort, limit = 100000, after } = params; - - const { tenant, locale, formId } = initialWhere; - - const where: Partial = { - ...initialWhere - }; - /** - * We need to remove conditions so we do not filter by them again. - */ - delete where.tenant; - delete where.locale; - delete where.formId; - - const queryAllParams: QueryAllParams = { - entity, - partitionKey: createSubmissionPartitionKey({ - tenant, - locale, - formId - }), - options: { - gte: " ", - reverse: true - } - }; - - let results; - try { - results = await queryAll(queryAllParams); - } catch (ex) { - throw new WebinyError( - ex.message || "Could list form submissions.", - ex.code || "LIST_SUBMISSIONS_ERROR", - { - where: initialWhere, - partitionKey: queryAllParams.partitionKey - } - ); - } - - const fields = plugins.byType( - FormSubmissionDynamoDbFieldPlugin.type + const deleteSubmission = async () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." ); - - const filteredSubmissions = filterItems({ - plugins, - items: results, - where, - fields - }); - - const sortedSubmissions = sortItems({ - items: filteredSubmissions, - sort, - fields - }); - - const totalCount = sortedSubmissions.length; - const start = parseInt(decodeCursor(after) || "0") || 0; - const hasMoreItems = totalCount > start + limit; - const end = limit > totalCount + start + limit ? undefined : start + limit; - const items = sortedSubmissions.slice(start, end); - /** - * Although we do not need a cursor here, we will use it as such to keep it standardized. - * Number is simply encoded. - */ - const cursor = items.length > 0 ? encodeCursor(start + limit) : null; - - const meta = { - hasMoreItems, - totalCount, - cursor - }; - - return { - items, - meta - }; }; - const getSubmission = async ( - params: FormBuilderStorageOperationsGetSubmissionParams - ): Promise => { - const { where } = params; - - const keys = { - PK: createSubmissionPartitionKey(where), - SK: createSubmissionSortKey(where.id) - }; - - try { - return await getClean({ entity, keys }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not oad submission.", - ex.code || "GET_SUBMISSION_ERROR", - { - where, - keys - } - ); - } + const listSubmissions = () => { + throw new Error( + "api-form-builder-so-ddb does not implement the Form Builder storage operations." + ); }; return { createSubmission, deleteSubmission, updateSubmission, - listSubmissions, - getSubmission, - createSubmissionPartitionKey, - createSubmissionSortKey + listSubmissions }; }; diff --git a/packages/api-form-builder-so-ddb/src/types.ts b/packages/api-form-builder-so-ddb/src/types.ts index adaf755fad4..40561090727 100644 --- a/packages/api-form-builder-so-ddb/src/types.ts +++ b/packages/api-form-builder-so-ddb/src/types.ts @@ -1,14 +1,13 @@ import { FormBuilderStorageOperations as BaseFormBuilderStorageOperations, FormBuilderSystemStorageOperations as BaseFormBuilderSystemStorageOperations, - FormBuilderSubmissionStorageOperations as BaseFormBuilderSubmissionStorageOperations, + FormBuilderSubmissionStorageOperations, FormBuilderSettingsStorageOperations as BaseFormBuilderSettingsStorageOperations, - FormBuilderFormStorageOperations as BaseFormBuilderFormStorageOperations + FormBuilderFormStorageOperations } from "@webiny/api-form-builder/types"; import { DynamoDBClient } from "@webiny/aws-sdk/client-dynamodb"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; +import { Entity } from "@webiny/db-dynamodb/toolbox"; import { AttributeDefinition } from "@webiny/db-dynamodb/toolbox"; -import { Plugin } from "@webiny/plugins"; export type Attributes = Record; @@ -23,7 +22,6 @@ export interface FormBuilderStorageOperationsFactoryParams { documentClient: DynamoDBClient; table?: string; attributes?: Record; - plugins?: Plugin; } export interface FormBuilderSystemCreateKeysParams { @@ -46,25 +44,12 @@ export interface FormBuilderFormCreateGSIPartitionKeyParams { tenant: string; locale: string; } - -export interface FormBuilderFormStorageOperations extends BaseFormBuilderFormStorageOperations { - createFormPartitionKey: (params: FormBuilderFormCreatePartitionKeyParams) => string; -} - export interface FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams { tenant: string; locale: string; formId: string; } -export interface FormBuilderSubmissionStorageOperations - extends BaseFormBuilderSubmissionStorageOperations { - createSubmissionPartitionKey: ( - params: FormBuilderSubmissionStorageOperationsCreatePartitionKeyParams - ) => string; - createSubmissionSortKey: (id: string) => string; -} - export interface FormBuilderSettingsStorageOperationsCreatePartitionKeyParams { tenant: string; locale: string; @@ -78,15 +63,14 @@ export interface FormBuilderSettingsStorageOperations createSettingsSortKey: () => string; } -export type Entities = "form" | "submission" | "system" | "settings"; +export type Entities = "system" | "settings"; export interface FormBuilderStorageOperations extends BaseFormBuilderStorageOperations, FormBuilderSettingsStorageOperations, - FormBuilderSubmissionStorageOperations, - FormBuilderFormStorageOperations, FormBuilderSystemStorageOperations { - getTable(): Table; + forms: FormBuilderFormStorageOperations; + submissions: FormBuilderSubmissionStorageOperations; getEntities(): Record>; } diff --git a/packages/api-form-builder-so-ddb/tsconfig.build.json b/packages/api-form-builder-so-ddb/tsconfig.build.json index ac484fb0fbf..c49df1e6cef 100644 --- a/packages/api-form-builder-so-ddb/tsconfig.build.json +++ b/packages/api-form-builder-so-ddb/tsconfig.build.json @@ -6,8 +6,6 @@ { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, - { "path": "../plugins/tsconfig.build.json" }, - { "path": "../utils/tsconfig.build.json" }, { "path": "../handler-db/tsconfig.build.json" } ], "compilerOptions": { diff --git a/packages/api-form-builder-so-ddb/tsconfig.json b/packages/api-form-builder-so-ddb/tsconfig.json index 2f22c781d6b..3fbdf7cfcdb 100644 --- a/packages/api-form-builder-so-ddb/tsconfig.json +++ b/packages/api-form-builder-so-ddb/tsconfig.json @@ -6,8 +6,6 @@ { "path": "../aws-sdk" }, { "path": "../db-dynamodb" }, { "path": "../error" }, - { "path": "../plugins" }, - { "path": "../utils" }, { "path": "../handler-db" } ], "compilerOptions": { @@ -25,10 +23,6 @@ "@webiny/db-dynamodb": ["../db-dynamodb/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], - "@webiny/plugins/*": ["../plugins/src/*"], - "@webiny/plugins": ["../plugins/src"], - "@webiny/utils/*": ["../utils/src/*"], - "@webiny/utils": ["../utils/src"], "@webiny/handler-db/*": ["../handler-db/src/*"], "@webiny/handler-db": ["../handler-db/src"] }, diff --git a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts index 246f5d029cd..dcb355f186a 100644 --- a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts +++ b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts @@ -93,19 +93,8 @@ describe("Forms Submission Security Test", () => { createdOn: expect.stringMatching(/^20/), savedOn: expect.stringMatching(/^20/), fields: [], - locked: false, - published: false, publishedOn: null, name: "A1-name", - overallStats: { - conversionRate: 0, - submissions: 0, - views: 0 - }, - stats: { - submissions: 0, - views: 0 - }, status: "draft", steps: [ { @@ -123,11 +112,6 @@ describe("Forms Submission Security Test", () => { } } }, - ownedBy: { - id: identityA.id, - displayName: identityA.displayName, - type: identityA.type - }, createdBy: { id: identityA.id, displayName: identityA.displayName, @@ -194,9 +178,7 @@ describe("Forms Submission Security Test", () => { } ], status: "published", - published: true, - publishedOn: expect.stringMatching(/^20/), - locked: true + publishedOn: expect.stringMatching(/^20/) }, error: null } diff --git a/packages/api-form-builder/__tests__/forms.test.ts b/packages/api-form-builder/__tests__/forms.test.ts index 0b29a520ec1..fcbafe0137e 100644 --- a/packages/api-form-builder/__tests__/forms.test.ts +++ b/packages/api-form-builder/__tests__/forms.test.ts @@ -25,6 +25,8 @@ describe('Form Builder "Form" Test', () => { createFormSubmission, listFormSubmissions, exportFormSubmissions, + getFormStats, + getFormOverallStats, defaultIdentity } = useGqlHandler(); @@ -55,8 +57,7 @@ describe('Form Builder "Form" Test', () => { createdOn: /^20/, savedOn: /^20/, status: "draft", - createdBy: defaultIdentity, - ownedBy: defaultIdentity + createdBy: defaultIdentity }, error: null } @@ -246,24 +247,65 @@ describe('Form Builder "Form" Test', () => { expect(list.data.formBuilder.listForms.data.length).toBe(0); }); + test("should create revision from specified revision", async () => { + // Create revision #1 + const [create1] = await createForm({ data: { name: "first" } }); + const data1 = create1.data.formBuilder.createForm.data; + + // Create revision #2 from revision #1 + const [create2] = await createRevisionFrom({ revision: data1.id }); + const data2 = create2.data.formBuilder.createRevisionFrom.data; + + // Update revision #2 name + const [update2] = await updateRevision({ + revision: data2.id, + data: { name: "second" } + }); + const data2updated = update2.data.formBuilder.updateRevision.data; + + // Create revision #3 from revision #1 + const [create3] = await createRevisionFrom({ revision: data1.id }); + + // Revision #3 data should match revision #1 + expect(create3.data.formBuilder.createRevisionFrom.data).toMatchObject({ + ...data1, + id: expect.any(String), + createdOn: /^20/, + savedOn: /^20/, + version: 3 + }); + + // Create revision #4 from revision #2 + const [create4] = await createRevisionFrom({ revision: data2.id }); + + // Revision #4 data should match revision #2 + expect(create4.data.formBuilder.createRevisionFrom.data).toMatchObject({ + ...data2updated, + id: expect.any(String), + createdOn: /^20/, + savedOn: /^20/, + version: 4 + }); + }); + test("should publish, add views and unpublish", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); - const { id } = create.data.formBuilder.createForm.data; + const { id, formId } = create.data.formBuilder.createForm.data; // Publish revision #1 await publishRevision({ revision: id }); // Get the published form - const [{ data: get }] = await getPublishedForm({ revision: id }); + const [{ data: get }] = await getPublishedForm({ parent: formId }); expect(get.formBuilder.getPublishedForm.data.id).toEqual(id); // Create a new revision const [create2] = await createRevisionFrom({ revision: id }); const { id: id2 } = create2.data.formBuilder.createRevisionFrom.data; - // Latest published form should still be #1 - const [latestPublished] = await getPublishedForm({ parent: id.split("#")[0] }); - expect(latestPublished.data.formBuilder.getPublishedForm.data.id).toEqual(id); + // Published form should still be #1 + const [published] = await getPublishedForm({ parent: formId }); + expect(published.data.formBuilder.getPublishedForm.data.id).toEqual(id); // Latest revision should be #2 const [list] = await listForms(); @@ -277,33 +319,40 @@ describe('Form Builder "Form" Test', () => { await saveFormView({ revision: id }); // Verify stats for #1 - const [{ data: get2 }] = await getForm({ revision: id }); - expect(get2.formBuilder.getForm.data.stats.views).toEqual(3); + const [{ data: get2 }] = await getFormStats({ id }); + expect(get2.formBuilder.getFormStats.data.views).toEqual(3); // Publish revision #2 await publishRevision({ revision: id2 }); - // Latest published form should now be #2 - const [latestPublished2] = await getPublishedForm({ parent: id.split("#")[0] }); - expect(latestPublished2.data.formBuilder.getPublishedForm.data.id).toEqual(id2); + // Published form should now be #2 + const [published2] = await getPublishedForm({ parent: formId }); + expect(published2.data.formBuilder.getPublishedForm.data.id).toEqual(id2); // Increment views for #2 await saveFormView({ revision: id2 }); await saveFormView({ revision: id2 }); // Verify stats for #2 - const [{ data: get3 }] = await getForm({ revision: id2 }); - expect(get3.formBuilder.getForm.data.stats.views).toEqual(2); + const [{ data: get3 }] = await getFormStats({ id: id2 }); + expect(get3.formBuilder.getFormStats.data.views).toEqual(2); // Verify overall stats - expect(get3.formBuilder.getForm.data.overallStats.views).toEqual(5); + const [{ data: get3overall }] = await getFormOverallStats({ id: id2 }); + expect(get3overall.formBuilder.getFormOverallStats.data.views).toEqual(5); // Unpublish #2 await unpublishRevision({ revision: id2 }); - // Latest published form should now again be #1 - const [latestPublished3] = await getPublishedForm({ parent: id.split("#")[0] }); - expect(latestPublished3.data.formBuilder.getPublishedForm.data.id).toEqual(id); + // There should be no published forms + const [published3] = await getPublishedForm({ parent: formId }); + expect(published3).toMatchObject({ + data: { + formBuilder: { + getPublishedForm: null + } + } + }); }); test("should create, list and export submissions to file", async () => { @@ -470,11 +519,6 @@ describe('Form Builder "Form" Test', () => { publishRevision: { data: { name: "form 2", - published: true, - stats: { - submissions: 0, - views: 0 - }, status: "published", version: 1 }, @@ -496,7 +540,6 @@ describe('Form Builder "Form" Test', () => { data: { id: `${form2.formId}#0002`, version: 2, - published: false, status: "draft" }, error: null @@ -530,11 +573,6 @@ describe('Form Builder "Form" Test', () => { publishRevision: { data: { name: "form 1", - published: true, - stats: { - submissions: 0, - views: 0 - }, status: "published", version: 1 }, @@ -556,7 +594,6 @@ describe('Form Builder "Form" Test', () => { data: { id: `${form1.formId}#0002`, version: 2, - published: false, status: "draft" }, error: null @@ -576,11 +613,6 @@ describe('Form Builder "Form" Test', () => { publishRevision: { data: { name: "form 1", - published: true, - stats: { - submissions: 0, - views: 0 - }, status: "published", version: 2 }, @@ -599,7 +631,6 @@ describe('Form Builder "Form" Test', () => { data: { id: `${form1.formId}#0003`, version: 3, - published: false, status: "draft" }, error: null diff --git a/packages/api-form-builder/__tests__/formsSecurity.test.ts b/packages/api-form-builder/__tests__/formsSecurity.test.ts index e6dc07cee1f..725dbc4be3e 100644 --- a/packages/api-form-builder/__tests__/formsSecurity.test.ts +++ b/packages/api-form-builder/__tests__/formsSecurity.test.ts @@ -15,8 +15,6 @@ class MockResponse { public readonly createdOn: RegExp; public readonly savedOn: RegExp; public readonly publishedOn: RegExp | null; - public readonly locked: boolean; - public readonly published: boolean; public readonly status: "published" | "draft"; public readonly version: number; @@ -26,8 +24,6 @@ class MockResponse { this.createdOn = /^20/; this.savedOn = /^20/; this.publishedOn = null; - this.locked = false; - this.published = false; this.status = "draft"; this.version = 1; } @@ -262,7 +258,7 @@ describe("Forms Security Test", () => { ...mock, steps: [ { - title: "", + title: "Step 1", layout: [] } ] @@ -276,7 +272,7 @@ describe("Forms Security Test", () => { ...new MockResponse({ prefix: `new-updated-form-`, id: formId }), steps: [ { - title: "", + title: "Step 1", layout: [] } ] @@ -438,9 +434,7 @@ describe("Forms Security Test", () => { id: formId }), publishedOn: /^20/, - published: true, - status: "published", - locked: true + status: "published" }, error: null } @@ -470,9 +464,7 @@ describe("Forms Security Test", () => { id: formId }), publishedOn: /^20/, - published: true, - status: "published", - locked: true + status: "published" }, error: null } @@ -518,6 +510,7 @@ describe("Forms Security Test", () => { prefix: "create-revision-form-", id }), + publishedOn: /^20/, status: "draft", version: i + 2 }, @@ -539,9 +532,7 @@ describe("Forms Security Test", () => { id }), publishedOn: /^20/, - published: true, status: "published", - locked: true, version: i + 2 }, error: null diff --git a/packages/api-form-builder/__tests__/graphql/formStats.ts b/packages/api-form-builder/__tests__/graphql/formStats.ts new file mode 100644 index 00000000000..1ffcdef1aa8 --- /dev/null +++ b/packages/api-form-builder/__tests__/graphql/formStats.ts @@ -0,0 +1,38 @@ +export const ERROR_FIELD = /* GraphQL */ ` + { + code + data + message + } +`; + +export const GET_FORM_STATS = /* GraphQL */ ` + query FbGetFormStats($id: ID!) { + formBuilder { + getFormStats(formId: $id) { + data { + id + formId + formVersion + views + submissions + } + error ${ERROR_FIELD} + } + } + } +`; + +export const GET_FORM_OVERALL_STATS = /* GraphQL */ ` + query FbGetFormOverallStats($id: ID!) { + formBuilder { + getFormOverallStats(formId: $id) { + data { + views + submissions + } + error ${ERROR_FIELD} + } + } + } +`; diff --git a/packages/api-form-builder/__tests__/graphql/forms.ts b/packages/api-form-builder/__tests__/graphql/forms.ts index d0c8dec6121..a73b331ac4c 100644 --- a/packages/api-form-builder/__tests__/graphql/forms.ts +++ b/packages/api-form-builder/__tests__/graphql/forms.ts @@ -26,28 +26,12 @@ export const FORM_DATA_FIELD = /* GraphQL */ ` } } triggers - published - locked status - stats { - views - submissions - } - overallStats { - views - submissions - conversionRate - } createdBy { id displayName type } - ownedBy { - id - displayName - type - } } `; @@ -58,10 +42,8 @@ export const FORMS_DATA_FIELD = /* GraphQL */ ` savedOn name slug - published publishedOn version - locked status createdBy { id diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts index c435b2e9efc..f6454dce3ce 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.ts +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -30,6 +30,7 @@ import { UNPUBLISH_REVISION, UPDATE_REVISION } from "./graphql/forms"; +import { GET_FORM_STATS, GET_FORM_OVERALL_STATS } from "./graphql/formStats"; import { CREATE_FROM_SUBMISSION, EXPORT_FORM_SUBMISSIONS, @@ -45,6 +46,7 @@ import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headl import { FormBuilderStorageOperations } from "~/types"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import { createPageBuilderContext } from "@webiny/api-page-builder"; +import { PageBuilderStorageOperations } from "@webiny/api-page-builder/types"; export interface UseGqlHandlerParams { permissions?: SecurityPermission[]; @@ -65,7 +67,7 @@ export default (params: UseGqlHandlerParams = {}) => { const { permissions, identity, plugins = [] } = params; const i18nStorage = getStorageOps("i18n"); const fileManagerStorage = getStorageOps("fileManager"); - const pageBuilderStorage = getStorageOps("pageBuilder"); + const pageBuilderStorage = getStorageOps("pageBuilder"); const formBuilderStorage = getStorageOps("formBuilder"); const cmsStorage = getStorageOps("cms"); @@ -213,6 +215,13 @@ export default (params: UseGqlHandlerParams = {}) => { async listForms(variables: Record = {}) { return invoke({ body: { query: LIST_FORMS, variables } }); }, + // Form Stats + async getFormStats(variables: Record) { + return invoke({ body: { query: GET_FORM_STATS, variables } }); + }, + async getFormOverallStats(variables: Record) { + return invoke({ body: { query: GET_FORM_OVERALL_STATS, variables } }); + }, // Form Submission async createFormSubmission(variables: Record) { return invoke({ body: { query: CREATE_FROM_SUBMISSION, variables } }); diff --git a/packages/api-form-builder/package.json b/packages/api-form-builder/package.json index 1e33d3c9537..e1af3e901d0 100644 --- a/packages/api-form-builder/package.json +++ b/packages/api-form-builder/package.json @@ -18,7 +18,6 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@commodo/fields": "1.1.2-beta.20", "@webiny/api": "0.0.0", "@webiny/api-file-manager": "0.0.0", "@webiny/api-i18n": "0.0.0", @@ -34,13 +33,13 @@ "@webiny/pubsub": "0.0.0", "@webiny/utils": "0.0.0", "@webiny/validation": "0.0.0", - "commodo-fields-object": "^1.0.6", "date-fns": "^2.22.1", "got": "^9.6.0", "json2csv": "^4.5.2", "lodash": "4.17.21", "node-fetch": "^2.6.1", - "slugify": "^1.2.9" + "slugify": "^1.2.9", + "zod": "^3.21.4" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormStatsStorage.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormStatsStorage.ts new file mode 100644 index 00000000000..c5527fc17f2 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormStatsStorage.ts @@ -0,0 +1,108 @@ +import { CmsEntry, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types"; +import { Security } from "@webiny/api-security/types"; +import { createIdentifier } from "@webiny/utils"; + +import { + FormBuilderStorageOperationsGetFormStatsParams, + FormBuilderStorageOperationsListFormStatsParams, + FormBuilderStorageOperationsCreateFormStatsParams, + FormBuilderStorageOperationsUpdateFormStatsParams, + FormBuilderStorageOperationsDeleteFormStatsParams, + FormBuilderFormStatsStorageOperations, + FbFormStats +} from "~/types"; + +interface ModelContext { + tenant: string; + locale: string; +} + +export class CmsFormStatsStorage implements FormBuilderFormStatsStorageOperations { + private readonly cms: HeadlessCms; + private readonly security: Security; + private readonly model: CmsModel; + + static async create(params: { model: CmsModel; cms: HeadlessCms; security: Security }) { + return new CmsFormStatsStorage(params.model, params.cms, params.security); + } + + private constructor(model: CmsModel, cms: HeadlessCms, security: Security) { + this.model = model; + this.cms = cms; + this.security = security; + } + + private modelWithContext({ tenant, locale }: ModelContext): CmsModel { + return { ...this.model, tenant, locale }; + } + + async getFormStats(params: FormBuilderStorageOperationsGetFormStatsParams) { + const { id, tenant, locale } = params.where; + const model = this.modelWithContext({ tenant, locale }); + + const entry = await this.security.withoutAuthorization(() => { + return this.cms.getEntry(model, { + where: { entryId: id, latest: true } + }); + }); + + return this.getFormStatsValues(entry); + } + + async listFormStats(params: FormBuilderStorageOperationsListFormStatsParams) { + const { tenant, locale, ...restWhere } = params.where; + const model = this.modelWithContext({ tenant, locale }); + + const [entries] = await this.security.withoutAuthorization(() => { + return this.cms.listEntries(model, { + where: { ...restWhere, latest: true } + }); + }); + + return entries.map(entry => this.getFormStatsValues(entry)); + } + + async createFormStats({ formStats }: FormBuilderStorageOperationsCreateFormStatsParams) { + const model = this.modelWithContext(formStats); + + const entry = await this.security.withoutAuthorization(() => { + return this.cms.createEntry(model, formStats); + }); + + return this.getFormStatsValues(entry); + } + + async updateFormStats({ formStats }: FormBuilderStorageOperationsUpdateFormStatsParams) { + const model = this.modelWithContext(formStats); + + // The version is set to 1, as `formStats` always has only one revision. + const formStatsRevisionId = createIdentifier({ + id: formStats.id, + version: 1 + }); + + const entry = await this.security.withoutAuthorization(() => { + return this.cms.updateEntry(model, formStatsRevisionId, formStats); + }); + + return this.getFormStatsValues(entry); + } + + async deleteFormStats(params: FormBuilderStorageOperationsDeleteFormStatsParams) { + const { ids, tenant, locale } = params; + const model = this.modelWithContext({ tenant, locale }); + + await this.security.withoutAuthorization(() => { + return this.cms.deleteMultipleEntries(model, { entries: ids }); + }); + } + + private getFormStatsValues(entry: CmsEntry) { + return { + id: entry.entryId, + locale: entry.locale, + tenant: entry.tenant, + ...entry.values + } as FbFormStats; + } +} diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormsStorage.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormsStorage.ts new file mode 100644 index 00000000000..f18b505ff40 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsFormsStorage.ts @@ -0,0 +1,212 @@ +import { CmsEntry, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types"; +import WebinyError from "@webiny/error"; +import { Security } from "@webiny/api-security/types"; +import { createIdentifier, parseIdentifier } from "@webiny/utils"; + +import { + FbForm, + FormBuilderStorageOperationsListFormsParams, + FormBuilderStorageOperationsListFormRevisionsParams, + FormBuilderStorageOperationsUpdateFormParams, + FormBuilderStorageOperationsDeleteFormRevisionParams, + FormBuilderStorageOperationsPublishFormParams, + FormBuilderStorageOperationsUnpublishFormParams, + FormBuilderStorageOperationsListFormsResponse, + FormBuilderStorageOperationsCreateFormParams, + FormBuilderStorageOperationsCreateFormFromParams, + FormBuilderStorageOperationsDeleteFormParams, + FormBuilderStorageOperationsGetFormParams, + FormBuilderFormStorageOperations +} from "~/types"; + +interface ModelContext { + tenant: string; + locale: string; +} + +export class CmsFormsStorage implements FormBuilderFormStorageOperations { + private readonly cms: HeadlessCms; + private readonly security: Security; + private readonly model: CmsModel; + + static async create(params: { model: CmsModel; cms: HeadlessCms; security: Security }) { + return new CmsFormsStorage(params.model, params.cms, params.security); + } + + private constructor(model: CmsModel, cms: HeadlessCms, security: Security) { + this.model = model; + this.cms = cms; + this.security = security; + } + + private modelWithContext({ tenant, locale }: ModelContext): CmsModel { + return { ...this.model, tenant, locale }; + } + + async getForm(params: FormBuilderStorageOperationsGetFormParams): Promise { + const { + id, + formId: initialFormId, + version, + published, + latest, + tenant, + locale + } = params.where; + const model = this.modelWithContext({ tenant, locale }); + const formId = initialFormId || parseIdentifier(id).id; + + const entry = await this.security.withoutAuthorization(async () => { + if (latest) { + const entry = await this.cms.getEntry(model, { + where: { entryId: formId, latest: true } + }); + + return entry; + } else if (published && !version) { + const [entry] = await this.cms.getPublishedEntriesByIds(model, [formId]); + + return entry; + } else if (id || version) { + const fallbackId = createIdentifier({ + id: formId, + version: version || 1 + }); + + return await this.cms.getEntryById(model, id || fallbackId); + } else if (latest && published) { + throw new WebinyError("Cannot have both latest and published params."); + } else { + throw new WebinyError("Missing parameter to get form", "MISSING_WHERE_PARAMETER", { + where: params.where + }); + } + }); + + return entry ? this.getFormFieldValues(entry) : null; + } + + async createForm(params: FormBuilderStorageOperationsCreateFormParams): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + const entry = await this.security.withoutAuthorization(() => { + return this.cms.createEntry(model, { ...form }); + }); + + return this.getFormFieldValues(entry); + } + + async createFormFrom( + params: FormBuilderStorageOperationsCreateFormFromParams + ): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + const entry = await this.security.withoutAuthorization(async () => { + return await this.cms.createEntryRevisionFrom(model, form.id, {}); + }); + + return this.getFormFieldValues(entry); + } + + async updateForm(params: FormBuilderStorageOperationsUpdateFormParams): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + const entry = await this.security.withoutAuthorization(async () => { + return await this.cms.updateEntry(model, form.id, form); + }); + + return this.getFormFieldValues(entry); + } + + async deleteForm(params: FormBuilderStorageOperationsDeleteFormParams): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + await this.security.withoutAuthorization(async () => { + return await this.cms.deleteEntry(model, form.id); + }); + } + + async deleteFormRevision( + params: FormBuilderStorageOperationsDeleteFormRevisionParams + ): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + await this.security.withoutAuthorization(async () => { + return await this.cms.deleteEntryRevision(model, form.id); + }); + } + + async listForms( + params: FormBuilderStorageOperationsListFormsParams + ): Promise { + const { tenant, locale, ...restWhere } = params.where; + const model = this.modelWithContext({ tenant, locale }); + + const [entries, meta] = await this.security.withoutAuthorization(async () => { + return await this.cms.listLatestEntries(model, { + after: params.after, + limit: params.limit, + sort: params.sort, + where: restWhere + }); + }); + + return [entries.map(entry => this.getFormFieldValues(entry)), meta]; + } + + async listFormRevisions( + params: FormBuilderStorageOperationsListFormRevisionsParams + ): Promise { + const { tenant, locale, formId } = params.where; + const model = this.modelWithContext({ tenant, locale }); + + const entries = await this.security.withoutAuthorization(async () => { + return await this.cms.getEntryRevisions(model, formId); + }); + + return entries.map(entry => this.getFormFieldValues(entry)); + } + + async publishForm(params: FormBuilderStorageOperationsPublishFormParams): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + const entry = await this.security.withoutAuthorization(async () => { + return await this.cms.publishEntry(model, form.id); + }); + + return this.getFormFieldValues(entry); + } + + async unpublishForm(params: FormBuilderStorageOperationsUnpublishFormParams): Promise { + const { form } = params; + const model = this.modelWithContext(form); + + const entry = await this.security.withoutAuthorization(async () => { + return await this.cms.unpublishEntry(model, form.id); + }); + + return this.getFormFieldValues(entry); + } + + private getFormFieldValues(entry: CmsEntry) { + return { + id: entry.id, + createdBy: entry.createdBy, + createdOn: entry.createdOn, + savedOn: entry.savedOn, + publishedOn: entry.lastPublishedOn, + status: entry.status, + locale: entry.locale, + tenant: entry.tenant, + webinyVersion: entry.webinyVersion, + version: entry.version, + ...entry.values + } as FbForm; + } +} diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/CmsSubmissionsStorage.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsSubmissionsStorage.ts new file mode 100644 index 00000000000..0655339f6a2 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/CmsSubmissionsStorage.ts @@ -0,0 +1,112 @@ +import omit from "lodash/omit"; + +import { CmsEntry, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types"; +import { Security } from "@webiny/api-security/types"; + +import { + FormBuilderStorageOperationsCreateSubmissionParams, + FormBuilderStorageOperationsUpdateSubmissionParams, + FormBuilderStorageOperationsListSubmissionsParams, + FormBuilderSubmissionStorageOperations, + FormBuilderStorageOperationsDeleteSubmissionParams, + FbSubmission +} from "~/types"; + +interface ModelContext { + tenant: string; + locale: string; +} + +export class CmsSubmissionsStorage implements FormBuilderSubmissionStorageOperations { + private readonly cms: HeadlessCms; + private readonly security: Security; + private readonly model: CmsModel; + + static async create(params: { model: CmsModel; cms: HeadlessCms; security: Security }) { + return new CmsSubmissionsStorage(params.model, params.cms, params.security); + } + + private constructor(model: CmsModel, cms: HeadlessCms, security: Security) { + this.model = model; + this.cms = cms; + this.security = security; + } + + private modelWithContext({ tenant, locale }: ModelContext): CmsModel { + return { ...this.model, tenant, locale }; + } + + async listSubmissions(params: FormBuilderStorageOperationsListSubmissionsParams) { + const { id_in, formId, tenant, locale } = params.where; + const model = this.modelWithContext({ tenant, locale }); + + const [entries, meta] = await this.security.withoutAuthorization(async () => { + return await this.cms.listLatestEntries(model, { + after: params.after, + limit: params.limit, + sort: params.sort, + where: { + entryId_in: id_in, + form: { parent: formId } + } + }); + }); + + return { items: entries.map(entry => this.getSubmissionValues(entry)), meta }; + } + + async createSubmission({ submission }: FormBuilderStorageOperationsCreateSubmissionParams) { + const model = this.modelWithContext(submission); + + const entry = await this.security.withoutAuthorization(() => { + return this.cms.createEntry(model, { ...submission }); + }); + + return this.getSubmissionValues(entry); + } + + async updateSubmission({ submission }: FormBuilderStorageOperationsUpdateSubmissionParams) { + const model = this.modelWithContext(submission); + + return await this.security.withoutAuthorization(async () => { + const entry = await this.cms.getEntry(model, { + where: { entryId: submission.id, latest: true } + }); + + const values = omit(submission, [ + "id", + "createdOn", + "createdBy", + "tenant", + "locale", + "webinyVersion" + ]); + + const updatedEntry = await this.cms.updateEntry(model, entry.id, values); + + return this.getSubmissionValues(updatedEntry); + }); + } + + async deleteSubmission(params: FormBuilderStorageOperationsDeleteSubmissionParams) { + const { submission } = params; + const model = this.modelWithContext(submission); + + return await this.security.withoutAuthorization(async () => { + return this.cms.deleteEntry(model, submission.id); + }); + } + + private getSubmissionValues(entry: CmsEntry) { + return { + id: entry.entryId, + createdBy: entry.createdBy, + createdOn: entry.createdOn, + savedOn: entry.savedOn, + locale: entry.locale, + tenant: entry.tenant, + webinyVersion: entry.webinyVersion, + ...entry.values + } as FbSubmission; + } +} diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/FormBuilderContextSetup.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/FormBuilderContextSetup.ts new file mode 100644 index 00000000000..b5ea53ceeff --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/FormBuilderContextSetup.ts @@ -0,0 +1,133 @@ +import { CmsModelPlugin } from "@webiny/api-headless-cms"; +import { AppPermissions } from "@webiny/api-security/utils/AppPermissions"; +import WebinyError from "@webiny/error"; + +import { createFormBuilderBasicContext } from "./createFormBuilderBasicContext"; +import { createFormBuilderPlugins } from "./createFormBuilderPlugins"; +import { CmsFormsStorage } from "./CmsFormsStorage"; +import { CmsFormStatsStorage } from "./CmsFormStatsStorage"; +import { isInstallationPending } from "./isInstallationPending"; +import { CmsSubmissionsStorage } from "./CmsSubmissionsStorage"; +import { FormBuilderContext, FbFormPermission, FormBuilderStorageOperations } from "~/types"; + +class FormsPermissions extends AppPermissions {} + +export class FormBuilderContextSetup { + private readonly context: FormBuilderContext; + + constructor(context: FormBuilderContext) { + this.context = context; + } + + async setupContext(storageOperations: FormBuilderStorageOperations) { + // This registers code plugins (model group, models) + const { + groupPlugin, + formModelDefinition, + formStatModelDefinition, + submissionModelDefinition + } = createFormBuilderPlugins(); + + // Finally, register all plugins + this.context.plugins.register([ + groupPlugin, + new CmsModelPlugin(formModelDefinition), + new CmsModelPlugin(formStatModelDefinition), + new CmsModelPlugin(submissionModelDefinition) + ]); + + const formsStorageOps = await this.context.security.withoutAuthorization(() => { + return this.setupFormsCmsStorageOperations(); + }); + + if (formsStorageOps) { + storageOperations.forms = formsStorageOps; + } + + const formsStatsStorageOps = await this.context.security.withoutAuthorization(() => { + return this.setupFormStatsCmsStorageOperations(); + }); + + if (formsStatsStorageOps) { + storageOperations.formStats = formsStatsStorageOps; + } + + const submissionsStorageOps = await this.context.security.withoutAuthorization(() => { + return this.setupSubmissionsCmsStorageOperations(); + }); + + if (submissionsStorageOps) { + storageOperations.submissions = submissionsStorageOps; + } + + const formsPermissions = new FormsPermissions({ + getIdentity: this.getIdentity.bind(this), + getPermissions: () => this.context.security.getPermissions("fb.form"), + fullAccessPermissionName: "fb.*" + }); + + return createFormBuilderBasicContext({ + storageOperations, + formsPermissions, + context: this.context + }); + } + + private getIdentity() { + return this.context.security.getIdentity(); + } + + private async setupFormsCmsStorageOperations() { + if (isInstallationPending({ tenancy: this.context.tenancy, i18n: this.context.i18n })) { + return; + } + + const model = await this.getModel("fbForm"); + + return await CmsFormsStorage.create({ + model, + cms: this.context.cms, + security: this.context.security + }); + } + + private async setupFormStatsCmsStorageOperations() { + if (isInstallationPending({ tenancy: this.context.tenancy, i18n: this.context.i18n })) { + return; + } + + const model = await this.getModel("fbFormStat"); + + return await CmsFormStatsStorage.create({ + model, + cms: this.context.cms, + security: this.context.security + }); + } + + private async setupSubmissionsCmsStorageOperations() { + if (isInstallationPending({ tenancy: this.context.tenancy, i18n: this.context.i18n })) { + return; + } + + const model = await this.getModel("fbSubmission"); + + return await CmsSubmissionsStorage.create({ + model, + cms: this.context.cms, + security: this.context.security + }); + } + + private async getModel(modelId: string) { + const model = await this.context.cms.getModel(modelId); + if (!model) { + throw new WebinyError({ + code: "MODEL_NOT_FOUND", + message: `Content model "${modelId}" was not found!` + }); + } + + return model; + } +} diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderBasicContext.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderBasicContext.ts new file mode 100644 index 00000000000..9d6d67d0e5b --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderBasicContext.ts @@ -0,0 +1,21 @@ +import { FormBuilderStorageOperations, FormBuilderContext } from "~/types"; +import { FormsPermissions } from "../plugins/crud/permissions/FormsPermissions"; +import { setupFormBuilderContext } from "../plugins/crud"; +import triggerHandlers from "../plugins/triggers"; +import validators from "../plugins/validators"; +import formBuilderPrerenderingPlugins from "~/plugins/prerenderingHooks"; + +export interface CreateFormBuilderParams { + storageOperations: FormBuilderStorageOperations; + formsPermissions: FormsPermissions; + context: FormBuilderContext; +} + +export const createFormBuilderBasicContext = (params: CreateFormBuilderParams) => { + return [ + setupFormBuilderContext(params), + triggerHandlers, + validators, + formBuilderPrerenderingPlugins() + ]; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderContext.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderContext.ts new file mode 100644 index 00000000000..0ff07e7d520 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderContext.ts @@ -0,0 +1,23 @@ +import { ContextPlugin } from "@webiny/api"; +import { FormBuilderContext, FormBuilderStorageOperations } from "~/types"; +import { FormBuilderContextSetup } from "./FormBuilderContextSetup"; +import { createGraphQLSchemaPlugin } from "./createGraphQLSchemaPlugin"; + +type CreateFormBuilderContextParams = { + storageOperations: FormBuilderStorageOperations; +}; + +export const createFormBuilderContext = ({ storageOperations }: CreateFormBuilderContextParams) => { + const plugin = new ContextPlugin(async context => { + const fbContext = new FormBuilderContextSetup(context); + await fbContext.setupContext(storageOperations); + }); + + plugin.name = "form-builder.createContext"; + + return plugin; +}; + +export const createFormBuilderGraphQL = () => { + return createGraphQLSchemaPlugin(); +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderPlugins.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderPlugins.ts new file mode 100644 index 00000000000..995f30cbe8c --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/createFormBuilderPlugins.ts @@ -0,0 +1,26 @@ +import { CmsGroupPlugin } from "@webiny/api-headless-cms"; +import { createFormDataModelDefinition } from "./models/form.model"; +import { createFormStatDataModelDefinition } from "./models/formStat.model"; +import { createSubmissionDataModelDefinition } from "./models/submission.model"; + +export const createFormBuilderPlugins = () => { + const groupId = "contentModelGroup_fb"; + + const groupPlugin = new CmsGroupPlugin({ + id: groupId, + slug: "formBuilder", + name: "Form Builder", + description: "Group for Form Builder models", + icon: "", + isPrivate: true + }); + + return { + groupPlugin, + formModelDefinition: createFormDataModelDefinition(groupPlugin.contentModelGroup), + formStatModelDefinition: createFormStatDataModelDefinition(groupPlugin.contentModelGroup), + submissionModelDefinition: createSubmissionDataModelDefinition( + groupPlugin.contentModelGroup + ) + }; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/createGraphQLSchemaPlugin.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/createGraphQLSchemaPlugin.ts new file mode 100644 index 00000000000..08d1fca0b02 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/createGraphQLSchemaPlugin.ts @@ -0,0 +1,73 @@ +import { ContextPlugin } from "@webiny/api"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords"; +import { createGraphQLSchemaPluginFromFieldPlugins } from "@webiny/api-headless-cms/utils/getSchemaFromFieldPlugins"; + +import { isInstallationPending } from "~/cmsFormBuilderStorage/isInstallationPending"; +import { createFormBuilderSettingsSchema } from "~/plugins/graphql/formSettings"; +import { createBaseSchema } from "~/plugins/graphql"; +import { createSubmissionsSchema } from "~/plugins/graphql/submissionsSchema"; +import { createFormsSchema } from "~/plugins/graphql/formsSchema"; +import { createFormStatsSchema } from "~/plugins/graphql/formStatsSchema"; +import { FormBuilderContext } from "~/types"; + +export const createGraphQLSchemaPlugin = () => { + return [ + createBaseSchema(), + createFormBuilderSettingsSchema(), + // Submission schema is generated dynamically, based on a CMS model, so we need to + // register it from a ContextPlugin, to perform additional bootstrap. + new ContextPlugin(async context => { + if (isInstallationPending(context)) { + return; + } + + await context.security.withoutAuthorization(async () => { + const submissionModel = (await context.cms.getModel("fbSubmission")) as CmsModel; + const formsModel = (await context.cms.getModel("fbForm")) as CmsModel; + const formStatsModel = (await context.cms.getModel("fbFormStat")) as CmsModel; + const models = await context.cms.listModels(); + const fieldPlugins = createFieldTypePluginRecords(context.plugins); + /** + * We need to register all plugins for all the CMS fields. + */ + const plugins = createGraphQLSchemaPluginFromFieldPlugins({ + models, + type: "manage", + fieldTypePlugins: fieldPlugins, + createPlugin: ({ schema, type, fieldType }) => { + const plugin = new GraphQLSchemaPlugin(schema); + plugin.name = `fb.graphql.schema.${type}.field.${fieldType}`; + return plugin; + } + }); + + const formsGraphQlPlugin = createFormsSchema({ + model: formsModel, + models, + plugins: fieldPlugins + }); + + const formStatsGraphQlPlugin = createFormStatsSchema({ + model: formStatsModel, + models, + plugins: fieldPlugins + }); + + const submissionsGraphQlPlugin = createSubmissionsSchema({ + model: submissionModel, + models, + plugins: fieldPlugins + }); + + context.plugins.register([ + ...plugins, + formsGraphQlPlugin, + formStatsGraphQlPlugin, + submissionsGraphQlPlugin + ]); + }); + }) + ]; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/createModelField.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/createModelField.ts new file mode 100644 index 00000000000..3d19f63da17 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/createModelField.ts @@ -0,0 +1,38 @@ +import { CmsModelField } from "@webiny/api-headless-cms/types"; +import camelCase from "lodash/camelCase"; + +export interface CreateModelFieldParams + extends Omit { + fieldId?: string; +} + +export const createModelField = (params: CreateModelFieldParams): CmsModelField => { + const { + label, + fieldId: initialFieldId, + type, + settings = {}, + listValidation = [], + validation = [], + multipleValues = false, + predefinedValues = { + values: [], + enabled: false + } + } = params; + + const fieldId = initialFieldId || camelCase(label); + + return { + id: fieldId, + storageId: `${type}@${fieldId}`, + fieldId, + label, + type, + settings, + listValidation, + validation, + multipleValues, + predefinedValues + }; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/isInstallationPending.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/isInstallationPending.ts new file mode 100644 index 00000000000..7ab12f1d503 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/isInstallationPending.ts @@ -0,0 +1,15 @@ +import { FormBuilderContext } from "~/types"; + +type CheckInstallationParams = Pick; +export const isInstallationPending = ({ tenancy, i18n }: CheckInstallationParams): boolean => { + /** + * In case of a fresh webiny project "tenant" and "locale" won't be there until + * installation is completed. So, we need to skip "storage" creation till then. + */ + const tenant = tenancy.getCurrentTenant(); + if (!tenant) { + return true; + } + + return !i18n.getContentLocale(); +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/modelFactory.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/modelFactory.ts new file mode 100644 index 00000000000..731dfb919f3 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/modelFactory.ts @@ -0,0 +1,26 @@ +import { CmsModelPlugin, CmsPrivateModelFull, createCmsModel } from "@webiny/api-headless-cms"; +import { CmsGroup } from "@webiny/api-headless-cms/types"; + +interface Params { + group: Pick; + /** + * Locale and tenant do not need to be defined. + * In that case model is not bound to any locale or tenant. + * You can bind it to locale, tenant, both or none. + */ + locale?: string; + tenant?: string; + modelDefinition: Omit; +} + +export const modelFactory = (params: Params): CmsModelPlugin => { + const { group, locale, tenant, modelDefinition } = params; + + return createCmsModel({ + group, + locale, + tenant, + ...modelDefinition, + noValidate: true + }); +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/models/form.model.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/models/form.model.ts new file mode 100644 index 00000000000..fd148efd52d --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/models/form.model.ts @@ -0,0 +1,362 @@ +import { CmsPrivateModelFull } from "@webiny/api-headless-cms"; +import { CmsModelField, CmsModelGroup } from "@webiny/api-headless-cms/types"; + +import { createModelField } from "../createModelField"; + +const required = () => { + return { + name: "required", + message: "Value is required." + }; +}; + +const formIdField = () => { + return createModelField({ + label: "Form ID", + fieldId: "formId", + type: "text", + validation: [required()] + }); +}; + +const nameField = () => { + return createModelField({ + label: "Name", + type: "text", + validation: [required()] + }); +}; + +const field_IdField = () => { + return createModelField({ + label: "ID", + fieldId: "_id", + type: "text", + validation: [required()] + }); +}; + +const fieldIdField = () => { + return createModelField({ + label: "FieldId", + type: "text", + validation: [required()] + }); +}; + +const fieldTypeField = () => { + return createModelField({ + label: "Type", + type: "text", + validation: [required()] + }); +}; + +const fieldNameField = () => { + return createModelField({ + label: "Name", + type: "text", + validation: [required()] + }); +}; + +const fieldLabelField = () => { + return createModelField({ + label: "Label", + type: "text", + validation: [required()] + }); +}; + +const fieldPlaceholderTextField = () => { + return createModelField({ + label: "PlaceholderText", + type: "text" + }); +}; + +const fieldHelpTextField = () => { + return createModelField({ + label: "HelpText", + type: "text" + }); +}; + +const fieldOptionsLabelField = () => { + return createModelField({ + label: "Label", + type: "text" + }); +}; + +const fieldOptionsValueField = () => { + return createModelField({ + label: "Value", + type: "text" + }); +}; + +const fieldOptionsField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Options", + type: "object", + multipleValues: true, + settings: { + fields + } + }); +}; + +const fieldValidationNameField = () => { + return createModelField({ + label: "Name", + type: "text", + validation: [required()] + }); +}; + +const fieldValidationMessageField = () => { + return createModelField({ + label: "Message", + type: "text" + }); +}; + +const fieldValidationSettingsField = () => { + return createModelField({ + label: "Settings", + type: "json" + }); +}; + +const fieldValidationField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Validation", + type: "object", + multipleValues: true, + settings: { + fields + } + }); +}; + +const fieldSettingsField = () => { + return createModelField({ + label: "Settings", + type: "json" + }); +}; + +export const fieldsField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Fields", + type: "object", + validation: [required()], + multipleValues: true, + settings: { + fields + } + }); +}; + +const stepLayoutField = () => { + return createModelField({ + label: "Layout", + type: "json", + validation: [required()], + multipleValues: true + }); +}; + +const stepTitleField = () => { + return createModelField({ + label: "Title", + type: "text", + validation: [required()] + }); +}; + +export const stepsField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Steps", + type: "object", + validation: [required()], + multipleValues: true, + settings: { + fields + } + }); +}; + +const settingsReCaptchaEnabledField = () => { + return createModelField({ + label: "Enabled", + type: "boolean" + }); +}; + +const settingsReCaptchaErrorMessageField = () => { + return createModelField({ + label: "ErrorMessage", + type: "text" + }); +}; + +const settingsReCaptchaField = (fields: CmsModelField[]) => { + return createModelField({ + label: "reCaptcha", + type: "object", + settings: { + fields + } + }); +}; + +const settingsLayoutRendererField = () => { + return createModelField({ + label: "Renderer", + type: "text" + }); +}; + +const settingsLayoutField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Layout", + type: "object", + settings: { + fields + } + }); +}; + +const settingsSubmitButtonLabelField = () => { + return createModelField({ + label: "SubmitButtonLabel", + type: "text" + }); +}; + +const settingsFullWidthSubmitButtonField = () => { + return createModelField({ + label: "FullWidthSubmitButton", + type: "boolean" + }); +}; + +const settingsSuccessMessageField = () => { + return createModelField({ + label: "SuccessMessage", + type: "json" + }); +}; + +const settingsTermsOfServiceMessageEnabledField = () => { + return createModelField({ + label: "Enabled", + fieldId: "enabled", + type: "boolean" + }); +}; + +const settingsTermsOfServiceMessageMessageField = () => { + return createModelField({ + label: "Message", + fieldId: "message", + type: "json" + }); +}; + +const settingsTermsOfServiceMessageErrorMessageField = () => { + return createModelField({ + label: "Error Message", + fieldId: "errorMessage", + type: "text" + }); +}; + +const settingsTermsOfServiceMessageField = (fields: CmsModelField[]) => { + return createModelField({ + label: "TermsOfServiceMessage", + type: "object", + settings: { + fields + } + }); +}; + +const settingsField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Settings", + type: "object", + settings: { + fields + } + }); +}; + +const triggersField = () => { + return createModelField({ + label: "Triggers", + type: "json" + }); +}; + +const slugField = () => { + return createModelField({ + label: "Slug", + fieldId: "slug", + type: "text" + }); +}; + +const SETTINGS_FIELDS: CmsModelField[] = [ + settingsLayoutField([settingsLayoutRendererField()]), + settingsSubmitButtonLabelField(), + settingsFullWidthSubmitButtonField(), + settingsSuccessMessageField(), + settingsTermsOfServiceMessageField([ + settingsTermsOfServiceMessageEnabledField(), + settingsTermsOfServiceMessageMessageField(), + settingsTermsOfServiceMessageErrorMessageField() + ]), + settingsReCaptchaField([settingsReCaptchaEnabledField(), settingsReCaptchaErrorMessageField()]) +]; + +export const FIELD_FIELDS = [ + field_IdField(), + fieldIdField(), + fieldTypeField(), + fieldNameField(), + fieldLabelField(), + fieldPlaceholderTextField(), + fieldHelpTextField(), + fieldOptionsField([fieldOptionsLabelField(), fieldOptionsValueField()]), + fieldValidationField([ + fieldValidationNameField(), + fieldValidationMessageField(), + fieldValidationSettingsField() + ]), + fieldSettingsField() +]; + +export const STEP_FIELDS = [stepTitleField(), stepLayoutField()]; + +export const createFormDataModelDefinition = (group: CmsModelGroup): CmsPrivateModelFull => { + return { + name: "FbForm", + modelId: "fbForm", + titleFieldId: "name", + fields: [ + formIdField(), + nameField(), + fieldsField(FIELD_FIELDS), + stepsField(STEP_FIELDS), + settingsField(SETTINGS_FIELDS), + triggersField(), + slugField() + ], + isPrivate: true, + group, + noValidate: true + }; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/models/formStat.model.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/models/formStat.model.ts new file mode 100644 index 00000000000..73790ef7086 --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/models/formStat.model.ts @@ -0,0 +1,59 @@ +import { CmsModelGroup } from "@webiny/api-headless-cms/types"; +import { CmsPrivateModelFull } from "@webiny/api-headless-cms"; + +import { createModelField } from "../createModelField"; + +const required = () => { + return { + name: "required", + message: "Value is required." + }; +}; + +const formIdField = () => { + return createModelField({ + label: "Form ID", + fieldId: "formId", + type: "text", + validation: [required()] + }); +}; + +const formVersionField = () => { + return createModelField({ + label: "Form Version", + fieldId: "formVersion", + type: "number", + validation: [required()] + }); +}; + +const viewsField = () => { + return createModelField({ + label: "Views", + fieldId: "views", + type: "number", + validation: [required()] + }); +}; + +const submissionsField = () => { + return createModelField({ + label: "Submissions", + fieldId: "submissions", + type: "number", + validation: [required()] + }); +}; + +export const createFormStatDataModelDefinition = (group: CmsModelGroup): CmsPrivateModelFull => { + return { + name: "FbFormStat", + modelId: "fbFormStat", + titleFieldId: "", + group, + fields: [formIdField(), formVersionField(), viewsField(), submissionsField()], + isPrivate: true, + noValidate: true + }; +}; diff --git a/packages/api-form-builder/src/cmsFormBuilderStorage/models/submission.model.ts b/packages/api-form-builder/src/cmsFormBuilderStorage/models/submission.model.ts new file mode 100644 index 00000000000..2fcd5adce5c --- /dev/null +++ b/packages/api-form-builder/src/cmsFormBuilderStorage/models/submission.model.ts @@ -0,0 +1,163 @@ +import { CmsModelField, CmsModelGroup } from "@webiny/api-headless-cms/types"; +import { CmsPrivateModelFull } from "@webiny/api-headless-cms"; + +import { fieldsField, stepsField, FIELD_FIELDS, STEP_FIELDS } from "./form.model"; +import { createModelField } from "../createModelField"; + +const required = () => { + return { + name: "required", + message: "Value is required." + }; +}; + +const dataField = () => { + return createModelField({ + label: "Data", + fieldId: "data", + type: "json", + validation: [required()] + }); +}; + +const metaIpField = () => { + return createModelField({ + label: "IP", + fieldId: "ip", + type: "text" + }); +}; + +const metaSubmittedOnField = () => { + return createModelField({ + label: "Submitted On", + fieldId: "submittedOn", + type: "datetime" + }); +}; + +const metaUrlLocationField = () => { + return createModelField({ + label: "Location", + fieldId: "location", + type: "text" + }); +}; + +const metaUrlQueryField = () => { + return createModelField({ + label: "Query", + fieldId: "query", + type: "json" + }); +}; + +const metaUrlField = (fields: CmsModelField[]) => { + return createModelField({ + label: "URL", + fieldId: "url", + type: "object", + settings: { + fields, + layout: fields.map(field => [field.storageId]) + } + }); +}; + +const metaField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Meta", + fieldId: "meta", + type: "object", + settings: { + fields, + layout: fields.map(field => [field.storageId]) + } + }); +}; + +const formIdField = () => { + return createModelField({ + label: "ID", + fieldId: "id", + type: "text", + validation: [required()] + }); +}; + +const formNameField = () => { + return createModelField({ + label: "Name", + fieldId: "name", + type: "text", + validation: [required()] + }); +}; + +const formParentField = () => { + return createModelField({ + label: "Parent", + fieldId: "parent", + type: "text", + validation: [required()] + }); +}; + +const versionField = () => { + return createModelField({ + label: "Version", + fieldId: "version", + type: "text", + validation: [required()] + }); +}; + +const formField = (fields: CmsModelField[]) => { + return createModelField({ + label: "Form", + fieldId: "form", + type: "object", + validation: [required()], + settings: { + fields, + layout: fields.map(field => [field.storageId]) + } + }); +}; + +const logsField = () => { + return createModelField({ + label: "Logs", + fieldId: "logs", + type: "json", + multipleValues: true + }); +}; + +export const createSubmissionDataModelDefinition = (group: CmsModelGroup): CmsPrivateModelFull => { + return { + name: "FbSubmission", + modelId: "fbSubmission", + titleFieldId: "", + group, + fields: [ + dataField(), + metaField([ + metaIpField(), + metaSubmittedOnField(), + metaUrlField([metaUrlLocationField(), metaUrlQueryField()]) + ]), + formField([ + formIdField(), + formNameField(), + formParentField(), + versionField(), + fieldsField(FIELD_FIELDS), + stepsField(STEP_FIELDS) + ]), + logsField() + ], + isPrivate: true, + noValidate: true + }; +}; diff --git a/packages/api-form-builder/src/index.ts b/packages/api-form-builder/src/index.ts index 53cc151ac07..d1ecd60ec43 100644 --- a/packages/api-form-builder/src/index.ts +++ b/packages/api-form-builder/src/index.ts @@ -1,24 +1,18 @@ -import createCruds from "./plugins/crud"; -import graphql from "./plugins/graphql"; -import triggerHandlers from "./plugins/triggers"; -import validators from "./plugins/validators"; -import formsGraphQL from "./plugins/graphql/form"; -import formSettingsGraphQL from "./plugins/graphql/formSettings"; -import formBuilderPrerenderingPlugins from "~/plugins/prerenderingHooks"; -import { FormBuilderStorageOperations } from "~/types"; +import { FormBuilderStorageOperations, FormBuilderContext } from "~/types"; +import { FormsPermissions } from "./plugins/crud/permissions/FormsPermissions"; +import { + createFormBuilderContext, + createFormBuilderGraphQL +} from "./cmsFormBuilderStorage/createFormBuilderContext"; export interface CreateFormBuilderParams { storageOperations: FormBuilderStorageOperations; + formsPermissions: FormsPermissions; + context: FormBuilderContext; } -export const createFormBuilder = (params: CreateFormBuilderParams) => { - return [ - createCruds(params), - graphql, - triggerHandlers, - validators, - formsGraphQL, - formSettingsGraphQL, - formBuilderPrerenderingPlugins() - ]; +export const createFormBuilder = (storageOperations: { + storageOperations: FormBuilderStorageOperations; +}) => { + return [createFormBuilderContext(storageOperations), createFormBuilderGraphQL()]; }; diff --git a/packages/api-form-builder/src/plugins/crud/formStats.crud.ts b/packages/api-form-builder/src/plugins/crud/formStats.crud.ts new file mode 100644 index 00000000000..194ae0e4fc8 --- /dev/null +++ b/packages/api-form-builder/src/plugins/crud/formStats.crud.ts @@ -0,0 +1,230 @@ +import { NotFoundError } from "@webiny/handler-graphql"; +import { createTopic } from "@webiny/pubsub"; +import WebinyError from "@webiny/error"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { I18NLocale } from "@webiny/api-i18n/types"; +import { parseIdentifier, zeroPad } from "@webiny/utils"; + +import { + FbFormStats, + FormBuilder, + FormStatsCRUD, + OnFormStatsBeforeCreate, + OnFormStatsAfterCreate, + OnFormStatsBeforeUpdate, + OnFormStatsAfterUpdate, + OnFormStatsBeforeDelete, + OnFormStatsAfterDelete +} from "~/types"; + +const getFormStatsId = (formRevisionId: string) => { + const { id: formId, version } = parseIdentifier(formRevisionId); + + if (version === null) { + throw new WebinyError("Wrong form revision id value", "GET_FORM_STATS_ID_ERROR", { + id: formRevisionId + }); + } + + return `${formId}-${zeroPad(version)}-stats`; +}; + +interface CreateFormStatsCrudParams { + getTenant: () => Tenant; + getLocale: () => I18NLocale; +} + +export const createFormStatsCrud = (params: CreateFormStatsCrudParams): FormStatsCRUD => { + const { getTenant, getLocale } = params; + + // create + const onFormStatsBeforeCreate = createTopic( + "formBuilder.onFormStatsBeforeCreate" + ); + const onFormStatsAfterCreate = createTopic( + "formBuilder.onFormStatsAfterCreate" + ); + // update + const onFormStatsBeforeUpdate = createTopic( + "formBuilder.onFormStatsBeforeUpdate" + ); + const onFormStatsAfterUpdate = createTopic( + "formBuilder.onFormStatsAfterUpdate" + ); + // delete + const onFormStatsBeforeDelete = createTopic( + "formBuilder.onFormStatsBeforeDelete" + ); + const onFormStatsAfterDelete = createTopic( + "formBuilder.onFormStatsAfterDelete" + ); + + return { + onFormStatsBeforeCreate, + onFormStatsAfterCreate, + onFormStatsBeforeUpdate, + onFormStatsAfterUpdate, + onFormStatsBeforeDelete, + onFormStatsAfterDelete, + async getFormStats(this: FormBuilder, formRevisionId) { + const id = getFormStatsId(formRevisionId); + + try { + const formStats = await this.storageOperations.formStats.getFormStats({ + where: { id, tenant: getTenant().id, locale: getLocale().code } + }); + + if (!formStats) { + throw new NotFoundError("Form stats not found."); + } + + return formStats; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load form stats.", + ex.code || "GET_FORM_STATS_ERROR", + { + id + } + ); + } + }, + async getFormOverallStats(this: FormBuilder, id) { + const { id: formId } = parseIdentifier(id); + + try { + const formStats = await this.storageOperations.formStats.listFormStats({ + where: { formId, tenant: getTenant().id, locale: getLocale().code } + }); + + if (!formStats?.length) { + throw new NotFoundError("Form overall stats not found."); + } + + const overallFormStats = { + formId, + views: 0, + submissions: 0, + tenant: getTenant().id, + locale: getLocale().code + }; + + formStats.forEach(stat => { + overallFormStats.views += stat.views; + overallFormStats.submissions += stat.submissions; + }); + + return overallFormStats; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not load form overall stats.", + ex.code || "GET_FORM_OVERALL_STATS_ERROR", + { + id + } + ); + } + }, + async createFormStats(this: FormBuilder, form) { + const id = getFormStatsId(form.id); + + const formStats: FbFormStats = { + id, + formId: form.formId, + formVersion: form.version, + views: 0, + submissions: 0, + tenant: getTenant().id, + locale: getLocale().code + }; + + try { + await onFormStatsBeforeCreate.publish({ + formStats + }); + const result = await this.storageOperations.formStats.createFormStats({ + formStats + }); + await onFormStatsAfterCreate.publish({ + formStats: result + }); + + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create form stats.", + ex.code || "CREATE_FORM_STATS_ERROR", + { + ...(ex.data || {}), + input: form, + formStats + } + ); + } + }, + async updateFormStats(this: FormBuilder, formRevisionId, input) { + const original = await this.getFormStats(formRevisionId); + + if (!original) { + throw new NotFoundError("Form stats not found."); + } + + const formStats = { ...original, ...input }; + + try { + await onFormStatsBeforeUpdate.publish({ + original, + formStats + }); + const result = await this.storageOperations.formStats.updateFormStats({ + formStats + }); + await onFormStatsAfterUpdate.publish({ + original, + formStats: result + }); + return result; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not update form stats.", + ex.code || "UPDATE_FORM_STATS_ERROR", + { + input, + original, + formStats + } + ); + } + }, + async deleteFormStats(this: FormBuilder, formId) { + const formStats = + (await this.storageOperations.formStats.listFormStats({ + where: { formId, tenant: getTenant().id, locale: getLocale().code } + })) || []; + + const formStatsIds = formStats.map(formStat => formStat.id); + + try { + await onFormStatsBeforeDelete.publish({ + ids: formStatsIds + }); + await this.storageOperations.formStats.deleteFormStats({ + ids: formStatsIds, + tenant: getTenant().id, + locale: getLocale().code + }); + await onFormStatsAfterDelete.publish({ + ids: formStatsIds + }); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not delete form stats.", + ex.code || "DELETE_FORM_STATS_ERROR", + { + ids: formStatsIds + } + ); + } + } + }; +}; diff --git a/packages/api-form-builder/src/plugins/crud/forms.crud.ts b/packages/api-form-builder/src/plugins/crud/forms.crud.ts index e750165325c..52d5da61d84 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.crud.ts @@ -1,9 +1,7 @@ import slugify from "slugify"; import { NotFoundError } from "@webiny/handler-graphql"; -import * as models from "./forms.models"; import { FbForm, - FbFormStats, FormBuilder, FormBuilderContext, FormBuilderStorageOperationsListFormsParams, @@ -21,14 +19,15 @@ import { OnFormRevisionBeforeCreateTopicParams, OnFormRevisionBeforeDeleteTopicParams, OnFormBeforeUnpublishTopicParams, - OnFormBeforeUpdateTopicParams + OnFormBeforeUpdateTopicParams, + FORM_STATUS } from "~/types"; import WebinyError from "@webiny/error"; import { Tenant } from "@webiny/api-tenancy/types"; import { I18NLocale } from "@webiny/api-i18n/types"; -import { createIdentifier, mdbid } from "@webiny/utils"; +import { createIdentifier, mdbid, parseIdentifier } from "@webiny/utils"; import { createTopic } from "@webiny/pubsub"; -import { getStatus } from "./utils"; +import { createFormSettings } from "./utils"; import { FormsPermissions } from "~/plugins/crud/permissions/FormsPermissions"; export interface CreateFormsCrudParams { @@ -114,7 +113,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { let form: FbForm | null = null; try { - form = await this.storageOperations.getForm({ + form = await this.storageOperations.forms.getForm({ where: { id, tenant: getTenant().id, @@ -136,45 +135,11 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { } if (options?.auth !== false) { - await formsPermissions.ensure({ owns: form.ownedBy }); + await formsPermissions.ensure({ owns: form.createdBy }); } return form; }, - async getFormStats(this: FormBuilder, id) { - /** - * We don't need to check permissions here, as this method is only called - * as a resolver to an `FbForm` GraphQL type, and we already check permissions - * and ownership when resolving the form in `getForm`. - */ - const revisions = await this.getFormRevisions(id, { - auth: false - }); - - /** - * Then calculate the stats - */ - const stats: FbFormStats = { - submissions: 0, - views: 0, - conversionRate: 0 - }; - - for (const form of revisions) { - stats.views += form.stats.views; - stats.submissions += form.stats.submissions; - } - - let conversionRate = 0; - if (stats.views > 0) { - conversionRate = parseFloat(((stats.submissions / stats.views) * 100).toFixed(2)); - } - - return { - ...stats, - conversionRate - }; - }, async listForms(this: FormBuilder) { await formsPermissions.ensure({ rwd: "r" }); @@ -190,13 +155,11 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { if (await formsPermissions.canAccessOnlyOwnRecords()) { const identity = context.security.getIdentity(); - listFormParams.where.ownedBy = identity.id; + listFormParams.where.createdBy = identity.id; } try { - const { items } = await this.storageOperations.listForms(listFormParams); - - return items; + return await this.storageOperations.forms.listForms(listFormParams); } catch (ex) { throw new WebinyError( ex.message || "Could not list all forms by given params", @@ -208,20 +171,18 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ); } }, - async getFormRevisions(this: FormBuilder, id, options) { - // Just get the form first, to check if it exists and if user has access to it. - const [pid, revisionNumber = "0001"] = id.split("#"); - await this.getForm(`${pid}#${revisionNumber}`, options); - + async getFormRevisions(this: FormBuilder, id) { try { - return await this.storageOperations.listFormRevisions({ + const result = await this.storageOperations.forms.listFormRevisions({ where: { - id, + formId: id, tenant: getTenant().id, locale: getLocale().code }, sort: ["version_ASC"] }); + + return result; } catch (ex) { throw new WebinyError( ex.message || "Could not list form revisions.", @@ -232,20 +193,20 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ); } }, - async getPublishedFormRevisionById(this: FormBuilder, id) { - const [formId, version] = id.split("#"); + async getPublishedFormRevisionById(this: FormBuilder, revisionId) { + const { id: formId, version } = parseIdentifier(revisionId); + if (!version) { throw new WebinyError("There is no version in given ID value.", "VERSION_ERROR", { - id + revisionId }); } let form: FbForm | null = null; try { - form = await this.storageOperations.getForm({ + form = await this.storageOperations.forms.getForm({ where: { formId, - version: Number(version), published: true, tenant: getTenant().id, locale: getLocale().code @@ -256,24 +217,19 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.message || "Could not load published form revision by ID.", ex.code || "GET_PUBLISHED_FORM_BY_ID_ERROR", { - id + revisionId } ); } if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + throw new NotFoundError(`Form "${revisionId}" was not found!`); } return form; }, - async getLatestPublishedFormRevision(this: FormBuilder, id) { - /** - * Make sure we have a unique form ID, and not a revision ID - */ - const [formId] = id.split("#"); - + async getLatestPublishedFormRevision(this: FormBuilder, formId) { let form: FbForm | null = null; try { - form = await this.storageOperations.getForm({ + form = await this.storageOperations.forms.getForm({ where: { formId, published: true, @@ -286,37 +242,29 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.message || "Could not load published form revision by ID.", ex.code || "GET_PUBLISHED_FORM_BY_ID_ERROR", { - id + formId } ); } if (!form) { - throw new NotFoundError(`Form "${id}" was not found!`); + throw new NotFoundError(`Form "${formId}" was not found!`); } return form; }, async createForm(this: FormBuilder, input) { await formsPermissions.ensure({ rwd: "w" }); const identity = context.security.getIdentity(); - const dataModel = new models.FormCreateDataModel().populate(input); - await dataModel.validate(); - - const data = await dataModel.toJSON(); /** * Forms are identified by a common parent ID + Revision number */ const formId = mdbid(); const version = 1; - const id = createIdentifier({ - id: formId, - version - }); - const slug = `${slugify(data.name)}-${formId}`.toLowerCase(); + const slug = `${slugify(input.name)}-${formId}`.toLowerCase(); const form: FbForm = { - id, + id: formId, formId, locale: getLocale().code, tenant: getTenant().id, @@ -327,25 +275,10 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { displayName: identity.displayName, type: identity.type }, - ownedBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - name: data.name, + name: input.name, slug, version, - locked: false, - published: false, - publishedOn: null, - status: getStatus({ - published: false, - locked: false - }), - stats: { - views: 0, - submissions: 0 - }, + status: FORM_STATUS.DRAFT, /** * Will be added via a "update" */ @@ -358,23 +291,21 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { layout: [] } ], - settings: await new models.FormSettingsModel().toJSON(), + settings: createFormSettings(), triggers: null, webinyVersion: context.WEBINY_VERSION }; + let result: FbForm; + try { await onFormBeforeCreate.publish({ form }); - const result = await this.storageOperations.createForm({ - input, - form - }); + result = await this.storageOperations.forms.createForm({ form }); await onFormAfterCreate.publish({ form: result }); - return result; } catch (ex) { throw new WebinyError( ex.message || "Could not create form.", @@ -384,14 +315,23 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { } ); } + + try { + await this.createFormStats(result); + } catch (ex) { + // If `formStats` creation fails, delete the form and rethrow the error. + // TODO: Consider adding a unit test to cover this scenario. + await this.deleteForm(result.id); + + throw ex; + } + + return result; }, async updateForm(this: FormBuilder, id, input) { await formsPermissions.ensure({ rwd: "w" }); - const updateData = new models.FormUpdateDataModel().populate(input); - await updateData.validate(); - const data = await updateData.toJSON({ onlyDirty: true }); - const original = await this.storageOperations.getForm({ + const original = await this.storageOperations.forms.getForm({ where: { id, tenant: getTenant().id, @@ -401,20 +341,17 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { if (!original) { throw new NotFoundError(`Form "${id}" was not found!`); - } else if (original.locked) { + } else if (original.status === FORM_STATUS.UNPUBLISHED) { throw new WebinyError("Not allowed to modify locked form.", "FORM_LOCKED_ERROR", { form: original }); } - await formsPermissions.ensure({ owns: original.ownedBy }); + await formsPermissions.ensure({ owns: original.createdBy }); const form: FbForm = { ...original, - ...data, - savedOn: new Date().toISOString(), - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION + ...input }; try { @@ -422,10 +359,8 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { form, original }); - const result = await this.storageOperations.updateForm({ - input: data, - form, - original + const result = await this.storageOperations.forms.updateForm({ + form }); await onFormAfterUpdate.publish({ form, @@ -437,7 +372,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.message || "Could not update form.", ex.code || "UPDATE_FORM_ERROR", { - input: data, + input, form, original } @@ -447,7 +382,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { async deleteForm(this: FormBuilder, id) { await formsPermissions.ensure({ rwd: "d" }); - const form = await this.storageOperations.getForm({ + const form = await this.storageOperations.forms.getForm({ where: { id, tenant: getTenant().id, @@ -459,19 +394,18 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { throw new NotFoundError(`Form ${id} was not found!`); } - await formsPermissions.ensure({ owns: form.ownedBy }); + await formsPermissions.ensure({ owns: form.createdBy }); try { await onFormBeforeDelete.publish({ form }); - await this.storageOperations.deleteForm({ + await this.storageOperations.forms.deleteForm({ form }); await onFormAfterDelete.publish({ form }); - return true; } catch (ex) { throw new WebinyError( ex.message || "Could not delete form.", @@ -481,6 +415,10 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { } ); } + + await this.deleteFormStats(form.formId); + + return true; }, async deleteFormRevision(this: FormBuilder, id) { await formsPermissions.ensure({ rwd: "d" }); @@ -489,13 +427,13 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { auth: false }); - await formsPermissions.ensure({ owns: form.ownedBy }); + await formsPermissions.ensure({ owns: form.createdBy }); - const formFormId = form.formId || form.id.split("#").pop(); + const { id: formId } = parseIdentifier(form.id); - const revisions = await this.storageOperations.listFormRevisions({ + const revisions = await this.storageOperations.forms.listFormRevisions({ where: { - formId: formFormId, + formId, tenant: form.tenant, locale: form.locale }, @@ -503,7 +441,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { }); const previous = revisions.find(rev => rev.version < form.version) || null; - if (!previous && revisions.length === 1) { + if (revisions.length === 1) { /** * Means we're deleting the last revision, so we need to delete the whole form. */ @@ -516,11 +454,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { previous, revisions }); - await this.storageOperations.deleteFormRevision({ - form, - previous, - revisions - }); + await this.storageOperations.forms.deleteFormRevision({ form }); await onFormRevisionAfterDelete.publish({ form, previous, @@ -541,36 +475,30 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { async publishForm(this: FormBuilder, id) { await formsPermissions.ensure({ rwd: "r", pw: "p" }); + const { id: pid, version } = parseIdentifier(id); + const formId = createIdentifier({ + id: pid, + version: version || 1 + }); + /** * getForm checks for existence of the form. */ - const original = await this.getForm(id, { + const form = await this.getForm(formId, { auth: false }); - await formsPermissions.ensure({ owns: original.ownedBy }); - - const form: FbForm = { - ...original, - published: true, - publishedOn: new Date().toISOString(), - locked: true, - savedOn: new Date().toISOString(), - status: getStatus({ published: true, locked: true }), - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; + await formsPermissions.ensure({ owns: form.createdBy }); try { await onFormBeforePublish.publish({ form }); - const result = await this.storageOperations.publishForm({ - original, + const result = await this.storageOperations.forms.publishForm({ form }); await onFormAfterPublish.publish({ - form + form: result }); return result; } catch (ex) { @@ -579,7 +507,6 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.code || "PUBLISH_FORM_ERROR", { ...(ex.data || {}), - original, form } ); @@ -588,27 +515,17 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { async unpublishForm(this: FormBuilder, id) { await formsPermissions.ensure({ rwd: "r", pw: "u" }); - const original = await this.getForm(id, { + const form = await this.getForm(id, { auth: false }); - await formsPermissions.ensure({ owns: original.ownedBy }); - - const form: FbForm = { - ...original, - published: false, - savedOn: new Date().toISOString(), - status: getStatus({ published: false, locked: true }), - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; + await formsPermissions.ensure({ owns: form.createdBy }); try { await onFormBeforeUnpublish.publish({ form }); - const result = await this.storageOperations.unpublishForm({ - original, + const result = await this.storageOperations.forms.unpublishForm({ form }); await onFormAfterUnpublish.publish({ @@ -621,7 +538,6 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.code || "UNPUBLISH_FORM_ERROR", { ...(ex.data || {}), - original, form } ); @@ -630,109 +546,57 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { async createFormRevision(this: FormBuilder, id) { await formsPermissions.ensure({ rwd: "w" }); - const original = await this.getForm(id, { + const form = await this.getForm(id, { auth: false }); - const originalFormFormId = original.formId || (original.id.split("#").pop() as string); - - const latest = await this.storageOperations.getForm({ - where: { - formId: originalFormFormId, - latest: true, - tenant: original.tenant, - locale: original.locale - } - }); - if (!latest) { - throw new WebinyError( - "Could not fetch latest form revision.", - "LATEST_FORM_REVISION_ERROR", - { - formId: originalFormFormId, - tenant: original.tenant, - locale: original.locale - } - ); - } - - const identity = context.security.getIdentity(); - const version = (latest ? latest.version : original.version) + 1; - - const form: FbForm = { - ...original, - id: createIdentifier({ - id: originalFormFormId, - version - }), - version, - stats: { - submissions: 0, - views: 0 - }, - savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - locked: false, - published: false, - publishedOn: null, - status: getStatus({ published: false, locked: false }), - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; + let result: FbForm; try { await onFormRevisionBeforeCreate.publish({ - original, - latest, form }); - const result = await this.storageOperations.createFormFrom({ - original, - latest, + result = await this.storageOperations.forms.createFormFrom({ form }); await onFormRevisionAfterCreate.publish({ - original, - latest, form: result }); - return result; } catch (ex) { throw new WebinyError( ex.message || "Could not create form from given one.", ex.code || "CREATE_FORM_FROM_ERROR", { ...(ex.data || {}), - original, form } ); } + + try { + await this.createFormStats(result); + } catch (ex) { + // If `formStats` creation fails, delete the form revision and rethrow the error. + // TODO: Consider adding a unit test to cover this scenario. + await this.deleteFormRevision(result.id); + + throw ex; + } + + return result; }, async incrementFormViews(this: FormBuilder, id) { - const original = await this.getForm(id, { - auth: false - }); + const original = await this.getFormStats(id); - const form: FbForm = { - ...original, - stats: { - ...original.stats, - views: original.stats.views + 1 - }, - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; + if (!original) { + throw new NotFoundError(`Form stats for form "${id}" were not found!`); + } + + const views = original.views + 1; try { - await this.storageOperations.updateForm({ - original, - form + await this.updateFormStats(id, { + views }); } catch (ex) { throw new WebinyError( @@ -740,7 +604,7 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { ex.code || "UPDATE_FORM_STATS_VIEWS_ERROR", { original, - form + views } ); } @@ -748,32 +612,23 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { return true; }, async incrementFormSubmissions(this: FormBuilder, id) { - const original = await this.getForm(id, { - auth: false - }); + const original = await this.getFormStats(id); - const form: FbForm = { - ...original, - stats: { - ...original.stats, - submissions: original.stats.submissions + 1 - }, - tenant: getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; + if (!original) { + throw new NotFoundError(`Form stats for form "${id}" were not found!`); + } + + const submissions = original.submissions + 1; try { - await this.storageOperations.updateForm({ - original, - form - }); + await this.updateFormStats(id, { submissions }); } catch (ex) { throw new WebinyError( ex.message || "Could not update form stats submissions stats.", ex.code || "UPDATE_FORM_STATS_SUBMISSIONS_ERROR", { original, - form + submissions } ); } diff --git a/packages/api-form-builder/src/plugins/crud/forms.models.ts b/packages/api-form-builder/src/plugins/crud/forms.models.ts deleted file mode 100644 index c1ae3c5c5e1..00000000000 --- a/packages/api-form-builder/src/plugins/crud/forms.models.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { validation } from "@webiny/validation"; -/** - * Package @commodo/fields does not have types. - */ -// @ts-expect-error -import { boolean, fields, string, withFields, number } from "@commodo/fields"; -/** - * Package commodo-fields-object does not have types. - */ -// @ts-expect-error -import { object } from "commodo-fields-object"; - -export const FormFieldsModel = withFields({ - _id: string({ validation: validation.create("required") }), - type: string({ validation: validation.create("required") }), - name: string({ validation: validation.create("required") }), - fieldId: string({ validation: validation.create("required") }), - /** - * Note: We've replaced "i18nString()" with "string()" - */ - label: string({ validation: validation.create("required") }), - helpText: string({}), - placeholderText: string({}), - options: fields({ - list: true, - value: [], - instanceOf: withFields({ - label: string({}), - value: string({ value: "" }) - })() - }), - validation: fields({ - list: true, - value: [], - instanceOf: withFields({ - name: string({ validation: validation.create("required") }), - message: string({}), - settings: object({ value: {} }) - })() - }), - settings: object({ value: {} }) -})(); - -export const FormStepsModel = withFields({ - steps: fields({ - value: {}, - instanceOf: withFields({ - title: string(), - layout: object({ value: [] }) - })() - }) -})(); - -export const FormSettingsModel = withFields({ - layout: fields({ - value: {}, - instanceOf: withFields({ - renderer: string({ value: "default" }) - })() - }), - /** - * Note: We've replaced "i18nString()" with "string()" - */ - submitButtonLabel: string({}), - fullWidthSubmitButton: boolean(), - /** - * Note: We've replaced "i18nObject()" with "object()" - */ - successMessage: object(), - termsOfServiceMessage: fields({ - instanceOf: withFields({ - message: object(), - errorMessage: string({}), - enabled: boolean() - })() - }), - reCaptcha: fields({ - value: {}, - instanceOf: withFields({ - enabled: boolean(), - /** - * Note: We've replaced "i18nString()" with "string()" - */ - errorMessage: string({ - value: "Please verify that you are not a robot." - }) - })() - }) -})(); - -export const FormCreateDataModel = withFields({ - name: string({ validation: validation.create("required") }) -})(); - -export const FormUpdateDataModel = withFields({ - name: string({}), - fields: fields({ - list: true, - value: [], - instanceOf: FormFieldsModel - }), - steps: object({ instanceOf: FormStepsModel, value: {} }), - settings: fields({ instanceOf: FormSettingsModel, value: {} }), - triggers: object() -})(); - -export const FormSubmissionCreateDataModel = withFields({ - data: object({ validation: validation.create("required") }), - meta: fields({ - value: {}, - instanceOf: withFields({ - ip: string({}), - submittedOn: string({ - value: new Date().toISOString() - }), - url: fields({ - value: {}, - instanceOf: withFields({ - location: string(), - query: object() - })() - }) - })() - }), - form: fields({ - instanceOf: withFields({ - id: string({ validation: validation.create("required") }), - parent: string({ validation: validation.create("required") }), - name: string({ validation: validation.create("required") }), - version: number({ validation: validation.create("required") }), - steps: object({ instanceOf: FormStepsModel, value: {} }), - fields: fields({ - list: true, - value: [], - instanceOf: FormFieldsModel - }) - })() - }) -})(); - -export const FormSubmissionUpdateDataModel = withFields({ - id: string({ validation: validation.create("required") }), - logs: fields({ - list: true, - value: [], - instanceOf: withFields({ - type: string({ - validation: validation.create("required,in:error:warning:info:success") - }), - message: string(), - data: object(), - createdOn: string({ value: new Date().toISOString() }) - })() - }) -})(); diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts index a4df2a2e981..0b5980a7fbe 100644 --- a/packages/api-form-builder/src/plugins/crud/index.ts +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -1,8 +1,8 @@ -import { FormBuilderContext, FormBuilderStorageOperations } from "~/types"; -import { ContextPlugin } from "@webiny/api"; +import { FormBuilderStorageOperations, FormBuilderContext } from "~/types"; import { createSystemCrud } from "~/plugins/crud/system.crud"; import { createSettingsCrud } from "~/plugins/crud/settings.crud"; import { createFormsCrud } from "~/plugins/crud/forms.crud"; +import { createFormStatsCrud } from "~/plugins/crud/formStats.crud"; import { createSubmissionsCrud } from "~/plugins/crud/submissions.crud"; import WebinyError from "@webiny/error"; import { FormsPermissions } from "./permissions/FormsPermissions"; @@ -10,99 +10,73 @@ import { SettingsPermissions } from "~/plugins/crud/permissions/SettingsPermissi export interface CreateFormBuilderCrudParams { storageOperations: FormBuilderStorageOperations; + context: FormBuilderContext; } -export default (params: CreateFormBuilderCrudParams) => { - const { storageOperations } = params; +export const setupFormBuilderContext = async (params: CreateFormBuilderCrudParams) => { + const { storageOperations, context } = params; - return new ContextPlugin(async context => { - const getLocale = () => { - const locale = context.i18n.getContentLocale(); - if (!locale) { - throw new WebinyError( - "Missing locale on context.i18n locale in API Form Builder.", - "LOCALE_ERROR" - ); - } - return locale; - }; - - const getIdentity = () => { - return context.security.getIdentity(); - }; - - const getTenant = () => { - return context.tenancy.getCurrentTenant(); - }; - - if (storageOperations.beforeInit) { - try { - await storageOperations.beforeInit(context); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not run before init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR", - { - ...ex - } - ); - } + const getLocale = () => { + const locale = context.i18n.getContentLocale(); + if (!locale) { + throw new WebinyError( + "Missing locale on context.i18n locale in API Form Builder.", + "LOCALE_ERROR" + ); } + return locale; + }; - const basePermissionsArgs = { - getIdentity, - fullAccessPermissionName: "fb.*" - }; + const getIdentity = () => { + return context.security.getIdentity(); + }; - const formsPermissions = new FormsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.form") - }); + const getTenant = () => { + return context.tenancy.getCurrentTenant(); + }; - const settingsPermissions = new SettingsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.settings") - }); + const basePermissionsArgs = { + getIdentity, + fullAccessPermissionName: "fb.*" + }; - context.formBuilder = { - storageOperations, - ...createSystemCrud({ - getIdentity, - getTenant, - getLocale, - context - }), - ...createSettingsCrud({ - getTenant, - getLocale, - settingsPermissions, - context - }), - ...createFormsCrud({ - getTenant, - getLocale, - formsPermissions, - context - }), - ...createSubmissionsCrud({ - context, - formsPermissions - }) - }; + const formsPermissions = new FormsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.form") + }); - if (!storageOperations.init) { - return; - } - try { - await storageOperations.init(context); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not run init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_INIT_ERROR", - { - ...ex - } - ); - } + const settingsPermissions = new SettingsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.settings") }); + + context.formBuilder = { + storageOperations, + ...createSystemCrud({ + getIdentity, + getTenant, + getLocale, + context + }), + ...createSettingsCrud({ + getTenant, + getLocale, + settingsPermissions, + context + }), + ...createFormsCrud({ + getTenant, + getLocale, + formsPermissions, + context + }), + ...createFormStatsCrud({ + getTenant, + getLocale + }), + ...createSubmissionsCrud({ + context, + formsPermissions + }) + }; }; diff --git a/packages/api-form-builder/src/plugins/crud/settings.crud.ts b/packages/api-form-builder/src/plugins/crud/settings.crud.ts index d0999ec84ca..cf60519df46 100644 --- a/packages/api-form-builder/src/plugins/crud/settings.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/settings.crud.ts @@ -17,6 +17,8 @@ import { I18NLocale } from "@webiny/api-i18n/types"; import { NotFoundError } from "@webiny/handler-graphql"; import { createTopic } from "@webiny/pubsub"; import { SettingsPermissions } from "./permissions/SettingsPermissions"; +import { removeUndefinedValues } from "@webiny/utils/removeUndefinedValues"; +import { createZodError } from "@webiny/utils/createZodError"; export interface CreateSettingsCrudParams { getTenant: () => Tenant; @@ -84,10 +86,13 @@ export const createSettingsCrud = (params: CreateSettingsCrudParams): SettingsCR return settings; }, async createSettings(this: FormBuilder, input) { - const formBuilderSettings = new models.CreateDataModel().populate(input); - await formBuilderSettings.validate(); + const result = await models.CreateDataModel().safeParseAsync(input); - const data = await formBuilderSettings.toJSON(); + if (!result.success) { + throw createZodError(result.error); + } + + const data = removeUndefinedValues(result.data); const original = await this.getSettings({ auth: false }); if (original) { @@ -133,10 +138,14 @@ export const createSettingsCrud = (params: CreateSettingsCrudParams): SettingsCR async updateSettings(this: FormBuilder, data) { await settingsPermissions.ensure(); - const updatedData = new models.UpdateDataModel().populate(data); - await updatedData.validate(); + const result = await models.UpdateDataModel().safeParseAsync(data); + + if (!result.success) { + throw createZodError(result.error); + } + + const updatedData: any = removeUndefinedValues(result.data); - const newSettings = await updatedData.toJSON({ onlyDirty: true }); const original = await this.getSettings(); if (!original) { throw new NotFoundError(`"Form Builder" settings not found!`); @@ -145,12 +154,12 @@ export const createSettingsCrud = (params: CreateSettingsCrudParams): SettingsCR /** * Assign specific properties, just to be sure nothing else gets in the record. */ - const settings = Object.keys(newSettings).reduce( - (collection, key) => { - if (newSettings[key] === undefined) { + const settings = Object.keys(updatedData).reduce( + (collection, key: string) => { + if (updatedData[key as keyof Settings] === undefined) { return collection; } - collection[key as keyof Settings] = newSettings[key]; + collection[key as keyof Settings] = updatedData[key]; return collection; }, { diff --git a/packages/api-form-builder/src/plugins/crud/settings.models.ts b/packages/api-form-builder/src/plugins/crud/settings.models.ts index 2fe94010fb2..767e8ce3648 100644 --- a/packages/api-form-builder/src/plugins/crud/settings.models.ts +++ b/packages/api-form-builder/src/plugins/crud/settings.models.ts @@ -1,30 +1,36 @@ -import { validation } from "@webiny/validation"; -/** - * Package @commodo/fields does not have types. - */ -// @ts-expect-error -import { withFields, string, boolean, fields } from "@commodo/fields"; +import zod from "zod"; -export const CreateDataModel = withFields({ - domain: string(), - reCaptcha: fields({ - value: {}, - instanceOf: withFields({ - enabled: boolean(), - siteKey: string({ validation: validation.create("maxLength:100") }), - secretKey: string({ validation: validation.create("maxLength:100") }) - })() - }) -})(); +export const CreateDataModel = () => { + return zod.object({ + domain: zod.string().default(""), + reCaptcha: zod + .object({ + enabled: zod.boolean().nullish().default(null), + siteKey: zod.string().max(100).nullish().default(null), + secretKey: zod.string().max(100).nullish().default(null) + }) + .default({ + enabled: null, + siteKey: null, + secretKey: null + }) + }); +}; -export const UpdateDataModel = withFields({ - domain: string(), - reCaptcha: fields({ - value: {}, - instanceOf: withFields({ - enabled: boolean(), - siteKey: string({ validation: validation.create("maxLength:100") }), - secretKey: string({ validation: validation.create("maxLength:100") }) - })() - }) -})(); +export const UpdateDataModel = () => { + return zod.object({ + domain: zod.string().default(""), + reCaptcha: zod + .object({ + enabled: zod.boolean().nullish().default(null), + siteKey: zod.string().max(100).nullish().default(null), + secretKey: zod.string().max(100).nullish().default(null) + }) + .nullish() + .default({ + enabled: null, + siteKey: null, + secretKey: null + }) + }); +}; diff --git a/packages/api-form-builder/src/plugins/crud/submissions.crud.ts b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts index 5e858ed1490..74767417c0f 100644 --- a/packages/api-form-builder/src/plugins/crud/submissions.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts @@ -1,7 +1,6 @@ import fetch from "node-fetch"; import pick from "lodash/pick"; import WebinyError from "@webiny/error"; -import * as models from "~/plugins/crud/forms.models"; import { FbForm, FbFormTriggerHandlerPlugin, @@ -24,7 +23,7 @@ import { NotFoundError } from "@webiny/handler-graphql"; import { NotAuthorizedError } from "@webiny/api-security"; import { createTopic } from "@webiny/pubsub"; import { sanitizeFormSubmissionData } from "~/plugins/crud/utils/sanitizeFormSubmissionData"; -import { mdbid } from "@webiny/utils"; +import { mdbid, parseIdentifier } from "@webiny/utils"; import { FormsPermissions } from "~/plugins/crud/permissions/FormsPermissions"; interface CreateSubmissionsCrudParams { @@ -99,7 +98,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm }; try { - const { items } = await this.storageOperations.listSubmissions( + const { items } = await this.storageOperations.submissions.listSubmissions( listSubmissionsParams ); @@ -156,7 +155,9 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm }; try { - const result = await this.storageOperations.listSubmissions(listSubmissionsParams); + const result = await this.storageOperations.submissions.listSubmissions( + listSubmissionsParams + ); return [result.items, result.meta]; } catch (ex) { @@ -269,11 +270,9 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm }; } - /** - * Use model for data validation and default values. - */ - const formFormId = form.formId || form.id.split("#").pop(); - const submissionModel = new models.FormSubmissionCreateDataModel().populate({ + const { id: formFormId } = parseIdentifier(form.id); + + const submissionModel = { data, meta, form: { @@ -284,12 +283,9 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm fields: form.fields, steps: form.steps } - }); - - await submissionModel.validate(); + }; - const modelData: Pick = - await submissionModel.toJSON(); + const modelData: Pick = submissionModel; const submission: FbSubmission = { ...modelData, @@ -297,7 +293,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm savedOn: new Date().toISOString(), id: mdbid(), locale: form.locale, - ownedBy: form.ownedBy, + createdBy: form.createdBy, tenant: form.tenant, logs: [], webinyVersion: context.WEBINY_VERSION @@ -308,7 +304,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm form, submission }); - await this.storageOperations.createSubmission({ + await this.storageOperations.submissions.createSubmission({ input: modelData, form, submission @@ -379,11 +375,6 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm return submission; }, async updateSubmission(this: FormBuilder, formId, input) { - const data = await new models.FormSubmissionUpdateDataModel().populate(input); - data.validate(); - - const updatedData = data.toJSON(); - const submissionId = input.id; const form = await this.getForm(formId, { @@ -401,7 +392,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm const submission: FbSubmission = { ...original, tenant: form.tenant, - logs: updatedData.logs, + logs: input.logs, webinyVersion: context.WEBINY_VERSION }; @@ -411,8 +402,8 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm original, submission }); - await this.storageOperations.updateSubmission({ - input: updatedData, + await this.storageOperations.submissions.updateSubmission({ + input, form, original, submission @@ -428,7 +419,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm ex.message || "Could not update form submission.", ex.code || "UPDATE_SUBMISSION_ERROR", { - input: updatedData, + input, original, submission, form: formId @@ -448,7 +439,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm form, submission }); - await this.storageOperations.deleteSubmission({ + await this.storageOperations.submissions.deleteSubmission({ form, submission }); diff --git a/packages/api-form-builder/src/plugins/crud/utils/createFormSettings.ts b/packages/api-form-builder/src/plugins/crud/utils/createFormSettings.ts new file mode 100644 index 00000000000..89ae1732545 --- /dev/null +++ b/packages/api-form-builder/src/plugins/crud/utils/createFormSettings.ts @@ -0,0 +1,23 @@ +/** + * Creates default settings values for form. + */ +export const createFormSettings = () => { + return { + layout: { + renderer: "default" + }, + submitButtonLabel: null, + fullWidthSubmitButton: null, + successMessage: null, + termsOfServiceMessage: null, + reCaptcha: { + enabled: null, + errorMessage: "Please verify that you are not a robot.", + settings: { + enabled: null, + siteKey: null, + secretKey: null + } + } + }; +}; diff --git a/packages/api-form-builder/src/plugins/crud/utils/getStatus.ts b/packages/api-form-builder/src/plugins/crud/utils/getStatus.ts deleted file mode 100644 index 04d68d21223..00000000000 --- a/packages/api-form-builder/src/plugins/crud/utils/getStatus.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const getStatus = (params: { published: boolean; locked: boolean }) => { - if (params.published) { - return "published"; - } - - return params.locked ? "locked" : "draft"; -}; diff --git a/packages/api-form-builder/src/plugins/crud/utils/index.ts b/packages/api-form-builder/src/plugins/crud/utils/index.ts index b89863bcfd9..39b965424eb 100644 --- a/packages/api-form-builder/src/plugins/crud/utils/index.ts +++ b/packages/api-form-builder/src/plugins/crud/utils/index.ts @@ -1,3 +1,3 @@ export * from "./flattenSubmissionMeta"; -export * from "./getStatus"; export * from "./sanitizeFormSubmissionData"; +export * from "./createFormSettings"; diff --git a/packages/api-form-builder/src/plugins/graphql.ts b/packages/api-form-builder/src/plugins/graphql.ts index c14043d04d0..5eb706b8f62 100644 --- a/packages/api-form-builder/src/plugins/graphql.ts +++ b/packages/api-form-builder/src/plugins/graphql.ts @@ -1,13 +1,11 @@ -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { ErrorResponse, Response } from "@webiny/handler-graphql"; import { FormBuilderContext } from "~/types"; const emptyResolver = () => ({}); -const plugin: GraphQLSchemaPlugin = { - type: "graphql-schema", - name: "graphql-schema-formBuilder", - schema: { +export const createBaseSchema = () => { + const baseSchema = new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type FbBooleanResponse { data: Boolean @@ -86,7 +84,7 @@ const plugin: GraphQLSchemaPlugin = { } } } - } -}; + }); -export default plugin; + return baseSchema; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/createFormStatsTypeDefs.ts b/packages/api-form-builder/src/plugins/graphql/createFormStatsTypeDefs.ts new file mode 100644 index 00000000000..ceb80e33a59 --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/createFormStatsTypeDefs.ts @@ -0,0 +1,59 @@ +import { CmsFieldTypePlugins, CmsModel } from "@webiny/api-headless-cms/types"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; + +export interface CreateFormStatsTypeDefsParams { + model: CmsModel; + models: CmsModel[]; + plugins: CmsFieldTypePlugins; +} + +export const createFormStatsTypeDefs = (params: CreateFormStatsTypeDefsParams): string => { + const { model, models, plugins: fieldTypePlugins } = params; + const { fields } = model; + + const fieldTypes = renderFields({ + models, + model, + fields, + type: "manage", + fieldTypePlugins + }); + + const excludeFormStatsFields = ["id", "formVersion"]; + + const formOverallStatsFieldTypes = renderFields({ + models, + model, + fields: model.fields.filter(field => !excludeFormStatsFields.includes(field.fieldId)), + type: "manage", + fieldTypePlugins + }); + + return /* GraphQL */ ` + ${fieldTypes.map(f => f.typeDefs).join("\n")} + + type FbFormStats { + id: ID! + ${fieldTypes.map(f => f.fields).join("\n")} + } + + type FbFormOverallStats { + ${formOverallStatsFieldTypes.map(f => f.fields).join("\n")} + } + + type FbFormStatsResponse { + data: FbFormStats + error: FbError + } + + type FbFormOverallStatsResponse { + data: FbFormOverallStats + error: FbError + } + + extend type FbQuery { + getFormStats(formId: ID!): FbFormStatsResponse + getFormOverallStats(formId: ID!): FbFormOverallStatsResponse + } + `; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/createFormsTypeDefs.ts b/packages/api-form-builder/src/plugins/graphql/createFormsTypeDefs.ts new file mode 100644 index 00000000000..617a2efd376 --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/createFormsTypeDefs.ts @@ -0,0 +1,170 @@ +import { CmsFieldTypePlugins, CmsModel, CmsModelField } from "@webiny/api-headless-cms/types"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; +import { renderInputFields } from "@webiny/api-headless-cms/utils/renderInputFields"; + +export interface CreateFormsTypeDefsParams { + model: CmsModel; + models: CmsModel[]; + plugins: CmsFieldTypePlugins; +} + +const removeFieldRequiredValidation = (field: CmsModelField) => { + if (field.validation) { + field.validation = field.validation.filter(validation => validation.name !== "required"); + } + if (field.listValidation) { + field.listValidation = field.listValidation.filter(v => v.name !== "required"); + } + return field; +}; + +const createUpdateFields = (fields: CmsModelField[]): CmsModelField[] => { + return fields.reduce((collection, field) => { + collection.push(removeFieldRequiredValidation({ ...field })); + return collection; + }, []); +}; + +export const createFormsTypeDefs = (params: CreateFormsTypeDefsParams): string => { + const { model, models, plugins: fieldTypePlugins } = params; + const { fields } = model; + + const fieldTypes = renderFields({ + models, + model, + fields, + type: "manage", + fieldTypePlugins + }); + + const inputFields = renderInputFields({ + models, + model, + fields, + fieldTypePlugins + }); + + const excludeFormFields = ["formId", "fields", "steps", "settings", "triggers"]; + + const inputCreateFields = renderInputFields({ + models, + model, + fields: model.fields.filter(field => !excludeFormFields.includes(field.fieldId)), + fieldTypePlugins + }); + + const excludeUpdateFormFields = ["formId"]; + + const inputUpdateFields = renderInputFields({ + models, + model, + fields: createUpdateFields( + model.fields.filter(field => !excludeUpdateFormFields.includes(field.fieldId)) + ), + fieldTypePlugins + }); + + return /* GraphQL */ ` + ${fieldTypes.map(f => f.typeDefs).join("\n")} + + type FbFormUser { + id: String + displayName: String + type: String + } + + type FbForm { + id: ID! + createdBy: FbFormUser! + createdOn: DateTime! + savedOn: DateTime! + publishedOn: DateTime + status: String! + version: Number! + ${fieldTypes.map(f => f.fields).join("\n")} + } + + ${inputFields.map(f => f.typeDefs).join("\n")} + + input FbCreateFormInput { + ${inputCreateFields.map(f => f.fields).join("\n")} + } + + input FbUpdateFormInput { + ${inputUpdateFields.map(f => f.fields).join("\n")} + } + + type FbFormResponse { + data: FbForm + error: FbError + } + + type FbFormListResponse { + data: [FbForm] + error: FbError + } + + type FbFormRevisionsResponse { + data: [FbForm] + error: FbError + } + + type FbSaveFormViewResponse { + error: FbError + } + + + type FbForm_Settings_ReCaptcha_Settings { + enabled: Boolean + secretKey: String + siteKey: String + } + + extend type FbForm_Settings_ReCaptcha { + settings: FbForm_Settings_ReCaptcha_Settings + } + + extend input FbForm_Settings_ReCaptchaInput { + settings: JSON + } + + extend type FbQuery { + # Get form (can be published or not, requires authorization ) + getForm(revision: ID!): FbFormResponse + + # List forms (returns a list of latest revision) + listForms: FbFormListResponse + + # Get form revisions + getFormRevisions(id: ID!): FbFormRevisionsResponse + + # Get published form by revision ID, or parent form ID (public access) + getPublishedForm(revision: ID, parent: ID): FbFormResponse + } + + extend type FbMutation { + createForm(data: FbCreateFormInput!): FbFormResponse + + # Create a new revision from an existing revision + createRevisionFrom(revision: ID!): FbFormResponse + + # Update revision + updateRevision(revision: ID!, data: FbUpdateFormInput!): FbFormResponse + + # Delete form and all of its revisions + deleteForm(id: ID!): FbDeleteResponse + + # Delete a single revision + deleteRevision(revision: ID!): FbDeleteResponse + + # Publish revision + publishRevision(revision: ID!): FbFormResponse + + # Unpublish revision + unpublishRevision(revision: ID!): FbFormResponse + + # Logs a view of a form + saveFormView(revision: ID!): FbSaveFormViewResponse + } + `; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/createSubmissionsTypeDefs.ts b/packages/api-form-builder/src/plugins/graphql/createSubmissionsTypeDefs.ts new file mode 100644 index 00000000000..f389bec65db --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/createSubmissionsTypeDefs.ts @@ -0,0 +1,95 @@ +import { CmsFieldTypePlugins, CmsModel } from "@webiny/api-headless-cms/types"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; +import { renderSortEnum } from "@webiny/api-headless-cms/utils/renderSortEnum"; + +export interface CreateSubmissionsTypeDefsParams { + model: CmsModel; + models: CmsModel[]; + plugins: CmsFieldTypePlugins; +} + +export const createSubmissionsTypeDefs = (params: CreateSubmissionsTypeDefsParams): string => { + const { model, models, plugins: fieldTypePlugins } = params; + const { fields } = model; + + const fieldTypes = renderFields({ + models, + model, + fields, + type: "manage", + fieldTypePlugins + }); + + const sortEnumRender = renderSortEnum({ + model, + fields: model.fields, + fieldTypePlugins, + sorterPlugins: [] + }); + + return /* GraphQL */ ` + ${fieldTypes.map(f => f.typeDefs).join("\n")} + + type FbFormSubmission { + id: ID! + savedOn: DateTime! + createdOn: DateTime! + createdBy: FmCreatedBy! + ${fieldTypes.map(f => f.fields).join("\n")} + } + + enum FbSubmissionSort { + ${sortEnumRender} + } + + type FbFormSubmissionResponse { + data: FbFormSubmission + error: FbError + } + + type FbListSubmissionsMeta { + cursor: String + hasMoreItems: Boolean + totalCount: Int + } + + type FbFormSubmissionsListResponse { + data: [FbFormSubmission] + meta: FbListSubmissionsMeta + error: FbError + } + + type FbExportFormSubmissionsFile { + src: String + key: String + } + + type FbExportFormSubmissionsResponse { + data: FbExportFormSubmissionsFile + error: FbError + } + + extend type FbQuery { + # List form submissions for specific Form + listFormSubmissions( + form: ID! + sort: [FbSubmissionSort!] + limit: Int + after: String + ): FbFormSubmissionsListResponse + } + + extend type FbMutation { + # Submits a form + createFormSubmission( + revision: ID! + data: JSON! + reCaptchaResponseToken: String + meta: JSON + ): FbFormSubmissionResponse + + # Export submissions as a CSV file + exportFormSubmissions(form: ID!): FbExportFormSubmissionsResponse + } + `; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts deleted file mode 100644 index b81bcca0a09..00000000000 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ /dev/null @@ -1,667 +0,0 @@ -import { parseAsync } from "json2csv"; -import { format } from "date-fns"; -import { - ErrorResponse, - ListResponse, - NotFoundResponse, - Response -} from "@webiny/handler-graphql/responses"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; -import { sanitizeFormSubmissionData, flattenSubmissionMeta } from "~/plugins/crud/utils"; -import { FormBuilderContext, FbFormField } from "~/types"; -import { mdbid } from "@webiny/utils"; - -const plugin: GraphQLSchemaPlugin = { - type: "graphql-schema", - schema: { - typeDefs: /* GraphQL */ ` - enum FbFormStatusEnum { - published - draft - locked - } - - type FbFormUser { - id: String - displayName: String - type: String - } - - type FbForm { - id: ID! - formId: ID! - createdBy: FbFormUser! - ownedBy: FbFormUser! - createdOn: DateTime! - savedOn: DateTime! - publishedOn: DateTime - version: Int! - name: String! - slug: String! - fields: [FbFormFieldType!]! - steps: [FbFormStepType!]! - settings: FbFormSettingsType! - triggers: JSON - published: Boolean! - locked: Boolean! - status: FbFormStatusEnum! - stats: FbFormStatsType! - overallStats: FbFormStatsType! - } - - type FbFieldOptionsType { - label: String - value: String - } - - input FbFormStepInput { - title: String - layout: [[String]] - } - - input FbFieldOptionsInput { - label: String - value: String - } - - input FbFieldValidationInput { - name: String! - message: String - settings: JSON - } - - type FbFieldValidationType { - name: String! - message: String - settings: JSON - } - - type FbFormStepType { - title: String - layout: [[String]] - } - - type FbFormFieldType { - _id: ID! - fieldId: String! - type: String! - name: String! - label: String - placeholderText: String - helpText: String - options: [FbFieldOptionsType] - validation: [FbFieldValidationType] - settings: JSON - } - - input FbFormFieldInput { - _id: ID! - fieldId: String! - type: String! - name: String! - label: String - placeholderText: String - helpText: String - options: [FbFieldOptionsInput] - validation: [FbFieldValidationInput] - settings: JSON - } - - type FbFormSettingsLayoutType { - renderer: String - } - - type FbTermsOfServiceMessage { - enabled: Boolean - message: JSON - errorMessage: String - } - - type FbFormReCaptchaSettings { - enabled: Boolean - siteKey: String - secretKey: String - } - - type FbReCaptcha { - enabled: Boolean - errorMessage: JSON - settings: FbFormReCaptchaSettings - } - - type FbFormSettingsType { - layout: FbFormSettingsLayoutType - submitButtonLabel: String - fullWidthSubmitButton: Boolean - successMessage: JSON - termsOfServiceMessage: FbTermsOfServiceMessage - reCaptcha: FbReCaptcha - } - - type FbFormStatsType { - views: Int - submissions: Int - conversionRate: Float - } - - input FbFormReCaptchaSettingsInput { - enabled: Boolean - siteKey: String - secretKey: String - } - - input FbReCaptchaInput { - enabled: Boolean - errorMessage: JSON - settings: FbFormReCaptchaSettingsInput - } - - input FbTermsOfServiceMessageInput { - enabled: Boolean - message: JSON - errorMessage: String - } - - input FbFormSettingsLayoutInput { - renderer: String - } - - input FbFormSettingsInput { - layout: FbFormSettingsLayoutInput - submitButtonLabel: String - fullWidthSubmitButton: Boolean - successMessage: JSON - termsOfServiceMessage: FbTermsOfServiceMessageInput - reCaptcha: FbReCaptchaInput - } - - input FbUpdateFormInput { - name: String - fields: [FbFormFieldInput] - steps: [FbFormStepInput] - settings: FbFormSettingsInput - triggers: JSON - } - - input FbFormSortInput { - name: Int - publishedOn: Int - } - - input FbCreateFormInput { - name: String! - } - - type FbFormResponse { - data: FbForm - error: FbError - } - - type FbFormListResponse { - data: [FbForm] - error: FbError - } - - type FbSaveFormViewResponse { - error: FbError - } - - type FbSubmissionFormData { - id: ID - parent: ID - name: String - version: Int - fields: [FbFormFieldType] - steps: [FbFormStepType] - } - - type FbFormSubmission { - id: ID - data: JSON - meta: FbSubmissionMeta - form: FbSubmissionFormData - } - - type FbSubmissionMetaUrl { - location: String - query: JSON - } - - type FbSubmissionMeta { - ip: String - submittedOn: DateTime - url: FbSubmissionMetaUrl - } - - type FbListSubmissionsMeta { - cursor: String - hasMoreItems: Boolean - totalCount: Int - } - - type FbFormSubmissionsListResponse { - data: [FbFormSubmission] - meta: FbListSubmissionsMeta - error: FbError - } - - type FbFormSubmissionResponse { - data: FbFormSubmission - error: FbError - } - - type FbFormRevisionsResponse { - data: [FbForm] - error: FbError - } - - type FbExportFormSubmissionsFile { - src: String - key: String - } - - type FbExportFormSubmissionsResponse { - data: FbExportFormSubmissionsFile - error: FbError - } - - enum FbSubmissionSort { - createdOn_ASC - createdOn_DESC - savedOn_ASC - savedOn_DESC - } - - extend type FbQuery { - # Get form (can be published or not, requires authorization ) - getForm(revision: ID!): FbFormResponse - - # Get form revisions - getFormRevisions(id: ID!): FbFormRevisionsResponse - - # Get published form by exact revision ID, or parent form ID (public access) - getPublishedForm(revision: ID, parent: ID): FbFormResponse - - # List forms (returns a list of latest revision) - listForms: FbFormListResponse - - # List form submissions for specific Form - listFormSubmissions( - form: ID! - sort: [FbSubmissionSort!] - limit: Int - after: String - ): FbFormSubmissionsListResponse - } - - extend type FbMutation { - createForm(data: FbCreateFormInput!): FbFormResponse - - # Create a new revision from an existing revision - createRevisionFrom(revision: ID!): FbFormResponse - - # Update revision - updateRevision(revision: ID!, data: FbUpdateFormInput!): FbFormResponse - - # Publish revision - publishRevision(revision: ID!): FbFormResponse - - # Unpublish revision - unpublishRevision(revision: ID!): FbFormResponse - - # Delete form and all of its revisions - deleteForm(id: ID!): FbDeleteResponse - - # Delete a single revision - deleteRevision(revision: ID!): FbDeleteResponse - - # Logs a view of a form - saveFormView(revision: ID!): FbSaveFormViewResponse - - # Submits a form - createFormSubmission( - revision: ID! - data: JSON! - reCaptchaResponseToken: String - meta: JSON - ): FbFormSubmissionResponse - - # Export submissions as a CSV file - exportFormSubmissions(form: ID!): FbExportFormSubmissionsResponse - } - `, - resolvers: { - FbForm: { - overallStats: async (form, _, { formBuilder }) => { - try { - return await formBuilder.getFormStats(form.id); - } catch (ex) { - console.log(`Could not fetch form "${form.id}" stats.`); - console.log(ex.message); - } - return { - views: 0, - submissions: 0, - conversionRate: 0 - }; - }, - settings: async (form, _, { formBuilder }) => { - const settings = await formBuilder.getSettings({ auth: false }); - - return { - ...form.settings, - reCaptcha: { - ...form.settings.reCaptcha, - settings: settings ? settings.reCaptcha : null - } - }; - } - }, - FbQuery: { - getForm: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.getForm(args.revision); - - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - getFormRevisions: async (_, args: any, { formBuilder }) => { - try { - const revisions = await formBuilder.getFormRevisions(args.id); - - return new Response(revisions); - } catch (e) { - return new ErrorResponse(e); - } - }, - listForms: async (_, __, { formBuilder }) => { - try { - const forms = await formBuilder.listForms(); - - return new ListResponse(forms); - } catch (e) { - return new ErrorResponse(e); - } - }, - getPublishedForm: async (_, args: any, { formBuilder }) => { - if (!args.revision && !args.parent) { - return new NotFoundResponse("Revision ID or Form ID missing."); - } - - let form; - - if (args.revision) { - /** - * This fetches the exact revision specified by revision ID - */ - form = await formBuilder.getPublishedFormRevisionById(args.revision); - } else if (args.parent) { - /** - * This fetches the latest published revision for given parent form - */ - form = await formBuilder.getLatestPublishedFormRevision(args.parent); - } - - if (!form) { - return new NotFoundResponse("The requested form was not found."); - } - - return new Response(form); - }, - listFormSubmissions: async (_, args: any, { formBuilder }) => { - try { - const { form, ...options } = args; - const [submissions, meta] = await formBuilder.listFormSubmissions( - form, - options - ); - return new ListResponse(submissions, meta); - } catch (err) { - return new ErrorResponse(err); - } - } - }, - FbMutation: { - /** - * Creates a new form - */ - createForm: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.createForm(args.data); - - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - /** - * Deletes the entire form with all of its revisions - */ - deleteForm: async (_, args: any, { formBuilder }) => { - try { - await formBuilder.deleteForm(args.id); - - return new Response(true); - } catch (e) { - return new ErrorResponse(e); - } - }, - /** - * Creates a revision from the given revision - */ - createRevisionFrom: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.createFormRevision(args.revision); - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - /** - * Updates revision - */ - updateRevision: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.updateForm(args.revision, args.data); - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - /** - * Publish revision (must be given an exact revision ID to publish) - */ - publishRevision: async (_, { revision }, { formBuilder }) => { - try { - const form = await formBuilder.publishForm(revision); - - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - unpublishRevision: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.unpublishForm(args.revision); - - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - /** - * Delete a revision - */ - deleteRevision: async (_, args: any, { formBuilder }) => { - try { - await formBuilder.deleteFormRevision(args.revision); - - return new Response(true); - } catch (e) { - return new ErrorResponse(e); - } - }, - saveFormView: async (_, args: any, { formBuilder }) => { - try { - const form = await formBuilder.incrementFormViews(args.revision); - - return new Response(form); - } catch (e) { - return new ErrorResponse(e); - } - }, - createFormSubmission: async (_: any, args: any, { formBuilder }) => { - const { revision, data, reCaptchaResponseToken, meta = {} } = args; - - try { - const formSubmission = await formBuilder.createFormSubmission( - revision, - reCaptchaResponseToken, - data, - meta - ); - - return new Response(formSubmission); - } catch (e) { - return new ErrorResponse(e); - } - }, - exportFormSubmissions: async (_: any, args: any, { formBuilder, fileManager }) => { - const { form } = args; - - try { - await formBuilder.onFormSubmissionsBeforeExport.publish({ form }); - const [submissions] = await formBuilder.listFormSubmissions(form, { - limit: 10000 - }); - - if (submissions.length === 0) { - return new NotFoundResponse("No form submissions found."); - } - - /** - * Get all revisions of the form. - */ - const revisions = await formBuilder.getFormRevisions(form); - const publishedRevisions = revisions.filter(r => r.published); - - const rows: Record[] = []; - const fields: Record = {}; - const fieldsData: FbFormField[] = []; - - /** - * First extract all distinct fields across all form submissions. - */ - for (let i = 0; i < publishedRevisions.length; i++) { - const revision = publishedRevisions[i]; - for (let j = 0; j < revision.fields.length; j++) { - const field = revision.fields[j]; - if (!fields[field.fieldId]) { - fieldsData.push(field); - fields[field.fieldId] = field.label; - - if (field?.options && field?.options?.length > 0) { - fields[`${field.fieldId}_label`] = `${field.label} (Label)`; - } - } - } - } - - /** - * Add meta fields. - */ - for (let i = 0; i < submissions.length; i++) { - const flattenedSubmissionMeta = flattenSubmissionMeta( - submissions[i].meta.url || {}, - "meta_url" - ); - - for (const metaKey in flattenedSubmissionMeta) { - if (!fields[metaKey]) { - fields[metaKey] = metaKey; - } - } - } - - /** - * Build rows. - */ - for (let i = 0; i < submissions.length; i++) { - const submissionData = sanitizeFormSubmissionData( - fieldsData, - submissions[i].data - ); - - const flattenedSubmissionMeta = flattenSubmissionMeta( - submissions[i].meta.url || {}, - "meta_url" - ); - for (const metaKey in flattenedSubmissionMeta) { - submissionData[metaKey] = flattenedSubmissionMeta[metaKey]; - } - - const row: Record = {}; - - row["Date submitted (UTC)"] = format( - new Date(submissions[i].createdOn), - "yyyy-MM-dd HH:mm:ss" - ); - - Object.keys(fields).map(fieldId => { - if (fieldId in submissionData) { - const value = submissionData[fieldId]; - // Remove brackets from arrays; - row[fields[fieldId]] = Array.isArray(value) - ? value.map(item => `"${item}"`).join(", ") - : value; - } else { - row[fields[fieldId]] = "N/A"; - } - }); - rows.push(row); - } - - /** - * Save CSV file and return its URL to the client. - */ - const csv = await parseAsync(rows, { - fields: ["Date submitted (UTC)", ...Object.values(fields)] - }); - - const buffer = Buffer.from(csv); - const id = mdbid(); - - const fileData = { - buffer, - id, - size: buffer.length, - name: "form_submissions_export.csv", - key: `${id}/form_submissions_export.csv`, - type: "text/csv", - keyPrefix: "form-submissions", - hideInFileManager: true - }; - - const { key } = await fileManager.storage.upload(fileData); - - const settings = await fileManager.getSettings(); - - const result = { - key, - src: (settings?.srcPrefix || "") + key - }; - await formBuilder.onFormSubmissionsAfterExport.publish({ result }); - - return new Response(result); - } catch (e) { - return new ErrorResponse(e); - } - } - } - } - } -}; - -export default plugin; diff --git a/packages/api-form-builder/src/plugins/graphql/formSettings.ts b/packages/api-form-builder/src/plugins/graphql/formSettings.ts index 17737092e83..814b6b7b4f0 100644 --- a/packages/api-form-builder/src/plugins/graphql/formSettings.ts +++ b/packages/api-form-builder/src/plugins/graphql/formSettings.ts @@ -1,10 +1,9 @@ -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { ErrorResponse, Response } from "@webiny/handler-graphql/responses"; import { FormBuilderContext } from "~/types"; -const plugin: GraphQLSchemaPlugin = { - type: "graphql-schema", - schema: { +export const createFormBuilderSettingsSchema = () => { + const formBuilderSettingGraphQL = new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type FbReCaptchaSettings { enabled: Boolean @@ -65,7 +64,7 @@ const plugin: GraphQLSchemaPlugin = { } } } - } -}; + }); -export default plugin; + return formBuilderSettingGraphQL; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/formStatsSchema.ts b/packages/api-form-builder/src/plugins/graphql/formStatsSchema.ts new file mode 100644 index 00000000000..751c9ebe8d2 --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/formStatsSchema.ts @@ -0,0 +1,38 @@ +import { ErrorResponse, GraphQLSchemaPlugin, Response } from "@webiny/handler-graphql"; + +import { + createFormStatsTypeDefs, + CreateFormStatsTypeDefsParams +} from "~/plugins/graphql/createFormStatsTypeDefs"; +import { FormBuilderContext } from "~/types"; + +export const createFormStatsSchema = (params: CreateFormStatsTypeDefsParams) => { + const formStatsGraphQL = new GraphQLSchemaPlugin({ + typeDefs: createFormStatsTypeDefs(params), + resolvers: { + FbQuery: { + getFormStats: async (_, args: any, { formBuilder }) => { + try { + const formStats = await formBuilder.getFormStats(args.formId); + + return new Response(formStats); + } catch (e) { + return new ErrorResponse(e); + } + }, + getFormOverallStats: async (_, args: any, { formBuilder }) => { + try { + const formStats = await formBuilder.getFormOverallStats(args.formId); + + return new Response(formStats); + } catch (e) { + return new ErrorResponse(e); + } + } + } + } + }); + formStatsGraphQL.name = "fb.graphql.formStats"; + + return formStatsGraphQL; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/formsSchema.ts b/packages/api-form-builder/src/plugins/graphql/formsSchema.ts new file mode 100644 index 00000000000..81aa8b1c359 --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/formsSchema.ts @@ -0,0 +1,181 @@ +import { + ErrorResponse, + GraphQLSchemaPlugin, + ListResponse, + NotFoundResponse, + Response +} from "@webiny/handler-graphql"; + +import { + createFormsTypeDefs, + CreateFormsTypeDefsParams +} from "~/plugins/graphql/createFormsTypeDefs"; +import { FbForm, FormBuilderContext } from "~/types"; + +export const createFormsSchema = (params: CreateFormsTypeDefsParams) => { + const formsGraphQL = new GraphQLSchemaPlugin({ + typeDefs: createFormsTypeDefs(params), + resolvers: { + FbForm: { + settings: async (form: FbForm, _, { formBuilder }) => { + const settings = await formBuilder.getSettings({ auth: false }); + + return { + ...form.settings, + reCaptcha: { + ...form.settings.reCaptcha, + settings: settings ? settings.reCaptcha : null + } + }; + } + }, + FbQuery: { + getForm: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.getForm(args.revision); + + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + getFormRevisions: async (_, args: any, { formBuilder }) => { + try { + const revisions = await formBuilder.getFormRevisions(args.id); + + return new Response(revisions); + } catch (e) { + return new ErrorResponse(e); + } + }, + listForms: async (_, __, { formBuilder }) => { + try { + const [data, meta] = await formBuilder.listForms(); + + return new ListResponse(data, meta); + } catch (e) { + return new ErrorResponse(e); + } + }, + getPublishedForm: async (_, args: any, { formBuilder }) => { + if (!args.revision && !args.parent) { + return new NotFoundResponse("Revision ID or Form ID missing."); + } + + let form; + + if (args.revision) { + /** + * This fetches the latest published revision for given revision id + */ + form = await formBuilder.getPublishedFormRevisionById(args.revision); + } else if (args.parent) { + /** + * This fetches the latest published revision for given parent form + */ + form = await formBuilder.getLatestPublishedFormRevision(args.parent); + } + + if (!form) { + return new NotFoundResponse("The requested form was not found."); + } + + return new Response(form); + } + }, + FbMutation: { + /** + * Creates a new form + */ + createForm: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.createForm(args.data); + + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + /** + * Deletes the entire form with all of its revisions + */ + deleteForm: async (_, args: any, { formBuilder }) => { + try { + await formBuilder.deleteForm(args.id); + + return new Response(true); + } catch (e) { + return new ErrorResponse(e); + } + }, + /** + * Creates a revision from the given revision + */ + createRevisionFrom: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.createFormRevision(args.revision); + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + /** + * Updates revision + */ + updateRevision: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.updateForm(args.revision, args.data); + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + /** + * Publish revision (must be given an exact revision ID to publish) + */ + publishRevision: async (_, { revision }, { formBuilder }) => { + try { + const form = await formBuilder.publishForm(revision); + + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + unpublishRevision: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.unpublishForm(args.revision); + + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + }, + /** + * Delete a revision + */ + deleteRevision: async (_, args: any, { formBuilder }) => { + try { + await formBuilder.deleteFormRevision(args.revision); + + return new Response(true); + } catch (e) { + return new ErrorResponse(e); + } + }, + saveFormView: async (_, args: any, { formBuilder }) => { + try { + const form = await formBuilder.incrementFormViews(args.revision); + + return new Response(form); + } catch (e) { + return new ErrorResponse(e); + } + } + } + } + }); + formsGraphQL.name = "fb.graphql.forms"; + + return formsGraphQL; +}; diff --git a/packages/api-form-builder/src/plugins/graphql/submissionsSchema.ts b/packages/api-form-builder/src/plugins/graphql/submissionsSchema.ts new file mode 100644 index 00000000000..a68f2dcaf74 --- /dev/null +++ b/packages/api-form-builder/src/plugins/graphql/submissionsSchema.ts @@ -0,0 +1,194 @@ +import { parseAsync } from "json2csv"; +import { format } from "date-fns"; + +import { mdbid } from "@webiny/utils"; +import { + ErrorResponse, + GraphQLSchemaPlugin, + ListResponse, + NotFoundResponse, + Response +} from "@webiny/handler-graphql"; + +import { sanitizeFormSubmissionData, flattenSubmissionMeta } from "~/plugins/crud/utils"; +import { + createSubmissionsTypeDefs, + CreateSubmissionsTypeDefsParams +} from "~/plugins/graphql/createSubmissionsTypeDefs"; +import { FormBuilderContext, FbFormField, FORM_STATUS } from "~/types"; + +export const createSubmissionsSchema = (params: CreateSubmissionsTypeDefsParams) => { + const submissionsGraphQL = new GraphQLSchemaPlugin({ + typeDefs: createSubmissionsTypeDefs(params), + resolvers: { + FbQuery: { + listFormSubmissions: async (_, args: any, { formBuilder }) => { + try { + const { form, ...options } = args; + const [submissions, meta] = await formBuilder.listFormSubmissions( + form, + options + ); + return new ListResponse(submissions, meta); + } catch (err) { + return new ErrorResponse(err); + } + } + }, + FbMutation: { + createFormSubmission: async (_: any, args: any, { formBuilder }) => { + const { revision, data, reCaptchaResponseToken, meta = {} } = args; + + try { + const formSubmission = await formBuilder.createFormSubmission( + revision, + reCaptchaResponseToken, + data, + meta + ); + + return new Response(formSubmission); + } catch (e) { + return new ErrorResponse(e); + } + }, + exportFormSubmissions: async (_: any, args: any, { formBuilder, fileManager }) => { + const { form } = args; + + try { + await formBuilder.onFormSubmissionsBeforeExport.publish({ form }); + const [submissions] = await formBuilder.listFormSubmissions(form, { + limit: 10000 + }); + + if (submissions.length === 0) { + return new NotFoundResponse("No form submissions found."); + } + + /** + * Get all revisions of the form. + */ + const revisions = await formBuilder.getFormRevisions(form); + const publishedRevisions = revisions.filter( + r => r.status === FORM_STATUS.PUBLISHED + ); + + const rows: Record[] = []; + const fields: Record = {}; + const fieldsData: FbFormField[] = []; + + /** + * First extract all distinct fields across all form submissions. + */ + for (let i = 0; i < publishedRevisions.length; i++) { + const revision = publishedRevisions[i]; + for (let j = 0; j < revision.fields.length; j++) { + const field = revision.fields[j]; + if (!fields[field.fieldId]) { + fieldsData.push(field); + fields[field.fieldId] = field.label; + + if (field?.options && field?.options?.length > 0) { + fields[`${field.fieldId}_label`] = `${field.label} (Label)`; + } + } + } + } + + /** + * Add meta fields. + */ + for (let i = 0; i < submissions.length; i++) { + const flattenedSubmissionMeta = flattenSubmissionMeta( + submissions[i].meta.url || {}, + "meta_url" + ); + + for (const metaKey in flattenedSubmissionMeta) { + if (!fields[metaKey]) { + fields[metaKey] = metaKey; + } + } + } + + /** + * Build rows. + */ + for (let i = 0; i < submissions.length; i++) { + const submissionData = sanitizeFormSubmissionData( + fieldsData, + submissions[i].data + ); + + const flattenedSubmissionMeta = flattenSubmissionMeta( + submissions[i].meta.url || {}, + "meta_url" + ); + for (const metaKey in flattenedSubmissionMeta) { + submissionData[metaKey] = flattenedSubmissionMeta[metaKey]; + } + + const row: Record = {}; + + row["Date submitted (UTC)"] = format( + new Date(submissions[i].createdOn), + "yyyy-MM-dd HH:mm:ss" + ); + + Object.keys(fields).map(fieldId => { + if (fieldId in submissionData) { + const value = submissionData[fieldId]; + // Remove brackets from arrays; + row[fields[fieldId]] = Array.isArray(value) + ? value.map(item => `"${item}"`).join(", ") + : value; + } else { + row[fields[fieldId]] = "N/A"; + } + }); + rows.push(row); + } + + /** + * Save CSV file and return its URL to the client. + */ + const csv = await parseAsync(rows, { + fields: ["Date submitted (UTC)", ...Object.values(fields)] + }); + + const buffer = Buffer.from(csv); + const id = mdbid(); + + const fileData = { + buffer, + id, + size: buffer.length, + name: "form_submissions_export.csv", + key: `${id}/form_submissions_export.csv`, + type: "text/csv", + keyPrefix: "form-submissions", + hideInFileManager: true + }; + + const { key } = await fileManager.storage.upload(fileData); + + const settings = await fileManager.getSettings(); + + const result = { + key, + src: (settings?.srcPrefix || "") + key + }; + await formBuilder.onFormSubmissionsAfterExport.publish({ result }); + + return new Response(result); + } catch (e) { + return new ErrorResponse(e); + } + } + } + } + }); + submissionsGraphQL.name = "fb.graphql.submissions"; + + return submissionsGraphQL; +}; diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index 8d69c9ba340..24b7df9a135 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -2,9 +2,16 @@ import { Plugin } from "@webiny/plugins/types"; import { TenancyContext } from "@webiny/api-tenancy/types"; import { SecurityPermission } from "@webiny/api-security/types"; import { FileManagerContext } from "@webiny/api-file-manager/types"; +import { + CmsEntryListWhere, + CmsEntryStatus as FormStatus, + CmsIdentity as FormIdentity +} from "@webiny/api-headless-cms/types"; import { I18NContext } from "@webiny/api-i18n/types"; import { Topic } from "@webiny/pubsub/types"; +export { CONTENT_ENTRY_STATUS as FORM_STATUS } from "@webiny/api-headless-cms/types"; + interface FbFormTriggerData { urls?: string[]; [key: string]: any; @@ -98,52 +105,34 @@ export interface FbForm { id: string; tenant: string; locale: string; - createdBy: CreatedBy; - ownedBy: OwnedBy; - savedOn: string; - createdOn: string; + createdBy: FormIdentity; + createdOn: Date | string; + savedOn: Date | string; + publishedOn?: Date | string; name: string; slug: string; version: number; - locked: boolean; - published: boolean; - publishedOn: string | null; - status: string; + status: FormStatus; fields: FbFormField[]; steps: FbFormStep[]; - stats: Omit; settings: Record; triggers: Record | null; formId: string; webinyVersion: string; } -export interface CreatedBy { - id: string; - displayName: string | null; - type: string; -} - -export type OwnedBy = CreatedBy; - interface FormCreateInput { name: string; } interface FormUpdateInput { name: string; - fields: Record[]; + fields: FbFormField[]; steps: FbFormStep[]; settings: Record; triggers: Record | null; } -export interface FbFormStats { - submissions: number; - views: number; - conversionRate: number; -} - interface FbListSubmissionsOptions { limit?: number; after?: string; @@ -175,13 +164,9 @@ export interface OnFormAfterCreateTopicParams { } export interface OnFormRevisionBeforeCreateTopicParams { form: FbForm; - original: FbForm; - latest: FbForm; } export interface OnFormRevisionAfterCreateTopicParams { form: FbForm; - original: FbForm; - latest: FbForm; } export interface OnFormBeforeUpdateTopicParams { form: FbForm; @@ -222,8 +207,7 @@ export interface OnFormAfterUnpublishTopicParams { export interface FormsCRUD { getForm(id: string, options?: FormBuilderGetFormOptions): Promise; - getFormStats(id: string): Promise; - listForms(): Promise; + listForms(): Promise; createForm(data: FormCreateInput): Promise; updateForm(id: string, data: Partial): Promise; deleteForm(id: string): Promise; @@ -232,7 +216,7 @@ export interface FormsCRUD { createFormRevision(fromRevisionId: string): Promise; incrementFormViews(id: string): Promise; incrementFormSubmissions(id: string): Promise; - getFormRevisions(id: string, options?: FormBuilderGetFormRevisionsOptions): Promise; + getFormRevisions(id: string): Promise; getPublishedFormRevisionById(revisionId: string): Promise; getLatestPublishedFormRevision(formId: string): Promise; deleteFormRevision(id: string): Promise; @@ -348,7 +332,6 @@ export interface SystemCRUD { export interface FbSubmission { id: string; locale: string; - ownedBy: OwnedBy; data: Record; meta: Record; form: { @@ -357,11 +340,11 @@ export interface FbSubmission { name: string; version: number; fields: Record[]; - layout: string[][]; steps: FbFormStep[]; }; logs: Record[]; createdOn: string; + createdBy: FormIdentity; savedOn: string; webinyVersion: string; tenant: string; @@ -374,9 +357,9 @@ export interface FbSubmission { export interface Settings { domain: string; reCaptcha: { - enabled: boolean; - siteKey: string; - secretKey: string; + enabled: boolean | null; + siteKey: string | null; + secretKey: string | null; }; tenant: string; locale: string; @@ -442,7 +425,12 @@ export interface FbFormSettingsPermission extends SecurityPermission { /** * The object representing form builder internals. */ -export interface FormBuilder extends SystemCRUD, SettingsCRUD, FormsCRUD, SubmissionsCRUD { +export interface FormBuilder + extends SystemCRUD, + SettingsCRUD, + FormsCRUD, + SubmissionsCRUD, + FormStatsCRUD { storageOperations: FormBuilderStorageOperations; } @@ -541,21 +529,16 @@ export interface FormBuilderStorageOperationsGetFormParams { }; } +export interface FormBuilderStorageOperationsListFormsWhereParams extends CmsEntryListWhere { + tenant: string; + locale: string; +} /** * @category StorageOperations * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsListFormsParams { - where: { - id?: string; - version?: number; - slug?: string; - published?: boolean; - ownedBy?: string; - latest?: boolean; - tenant: string; - locale: string; - }; + where: FormBuilderStorageOperationsListFormsWhereParams; after: string | null; limit: number; sort: string[]; @@ -566,8 +549,7 @@ export interface FormBuilderStorageOperationsListFormsParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsListFormRevisionsParamsWhere { - id?: string; - formId?: string; + formId: string; version_not?: number; publishedOn_not?: string | null; tenant: string; @@ -586,21 +568,21 @@ export interface FormBuilderStorageOperationsListFormRevisionsParams { * @category StorageOperations * @category StorageOperationsParams */ -export interface FormBuilderStorageOperationsListFormsResponse { - items: FbForm[]; - meta: { + +export type FormBuilderStorageOperationsListFormsResponse = [ + FbForm[], + { hasMoreItems: boolean; cursor: string | null; totalCount: number; - }; -} + } +]; /** * @category StorageOperations * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsCreateFormParams { - input: Record; form: FbForm; } @@ -609,8 +591,6 @@ export interface FormBuilderStorageOperationsCreateFormParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsCreateFormFromParams { - original: FbForm; - latest: FbForm; form: FbForm; } @@ -619,8 +599,6 @@ export interface FormBuilderStorageOperationsCreateFormFromParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsUpdateFormParams { - input?: Record; - original: FbForm; form: FbForm; } @@ -637,14 +615,6 @@ export interface FormBuilderStorageOperationsDeleteFormParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsDeleteFormRevisionParams { - /** - * Method always receives all the revisions of given form ordered by version_DESC. - */ - revisions: FbForm[]; - /** - * Previous revision of the current form. Always the first lesser available version. - */ - previous: FbForm | null; form: FbForm; } @@ -653,7 +623,6 @@ export interface FormBuilderStorageOperationsDeleteFormRevisionParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsPublishFormParams { - original: FbForm; form: FbForm; } @@ -662,7 +631,6 @@ export interface FormBuilderStorageOperationsPublishFormParams { * @category StorageOperationsParams */ export interface FormBuilderStorageOperationsUnpublishFormParams { - original: FbForm; form: FbForm; } @@ -748,25 +716,23 @@ export interface FormBuilderSettingsStorageOperations { */ export interface FormBuilderFormStorageOperations { getForm(params: FormBuilderStorageOperationsGetFormParams): Promise; - listForms( - params: FormBuilderStorageOperationsListFormsParams - ): Promise; - listFormRevisions( - params: FormBuilderStorageOperationsListFormRevisionsParams - ): Promise; createForm(params: FormBuilderStorageOperationsCreateFormParams): Promise; createFormFrom(params: FormBuilderStorageOperationsCreateFormFromParams): Promise; updateForm(params: FormBuilderStorageOperationsUpdateFormParams): Promise; /** * Delete all form revisions + latest + published. */ - deleteForm(params: FormBuilderStorageOperationsDeleteFormParams): Promise; + deleteForm(params: FormBuilderStorageOperationsDeleteFormParams): Promise; /** * Delete the single form revision. */ - deleteFormRevision( - params: FormBuilderStorageOperationsDeleteFormRevisionParams - ): Promise; + deleteFormRevision(params: FormBuilderStorageOperationsDeleteFormRevisionParams): Promise; + listForms( + params: FormBuilderStorageOperationsListFormsParams + ): Promise; + listFormRevisions( + params: FormBuilderStorageOperationsListFormRevisionsParams + ): Promise; publishForm(params: FormBuilderStorageOperationsPublishFormParams): Promise; unpublishForm(params: FormBuilderStorageOperationsUnpublishFormParams): Promise; } @@ -783,9 +749,6 @@ export interface FormBuilderStorageOperationsListSubmissionsResponse { * @category StorageOperations */ export interface FormBuilderSubmissionStorageOperations { - getSubmission( - params: FormBuilderStorageOperationsGetSubmissionParams - ): Promise; listSubmissions( params: FormBuilderStorageOperationsListSubmissionsParams ): Promise; @@ -795,18 +758,130 @@ export interface FormBuilderSubmissionStorageOperations { updateSubmission( params: FormBuilderStorageOperationsUpdateSubmissionParams ): Promise; - deleteSubmission( - params: FormBuilderStorageOperationsDeleteSubmissionParams - ): Promise; + deleteSubmission(params: FormBuilderStorageOperationsDeleteSubmissionParams): Promise; } /** * @category StorageOperations */ export interface FormBuilderStorageOperations extends FormBuilderSystemStorageOperations, - FormBuilderSettingsStorageOperations, - FormBuilderFormStorageOperations, - FormBuilderSubmissionStorageOperations { - beforeInit?: (context: FormBuilderContext) => Promise; - init?: (context: FormBuilderContext) => Promise; + FormBuilderSettingsStorageOperations { + forms: FormBuilderFormStorageOperations; + formStats: FormBuilderFormStatsStorageOperations; + submissions: FormBuilderSubmissionStorageOperations; +} + +export interface FbFormStats { + id: string; + formId: string; + formVersion: number; + views: number; + submissions: number; + tenant: string; + locale: string; +} + +/** + * FormStats CRUD Lifecycle Events + */ +export interface OnFormStatsBeforeCreate { + formStats: FbFormStats; +} +export interface OnFormStatsAfterCreate { + formStats: FbFormStats; +} +export interface OnFormStatsBeforeUpdate { + original: FbFormStats; + formStats: FbFormStats; +} +export interface OnFormStatsAfterUpdate { + original: FbFormStats; + formStats: FbFormStats; +} +export interface OnFormStatsBeforeDelete { + ids: string[]; +} +export interface OnFormStatsAfterDelete { + ids: string[]; +} + +export interface FormStatsCRUD { + getFormStats(formRevisionId: string): Promise; + getFormOverallStats(id: string): Promise | null>; + createFormStats(form: FbForm): Promise; + updateFormStats( + formRevisionId: string, + input: { views?: number; submissions?: number } + ): Promise; + deleteFormStats(formId: string): Promise; + /** + * Lifecycle events + */ + onFormStatsBeforeCreate: Topic; + onFormStatsAfterCreate: Topic; + onFormStatsBeforeUpdate: Topic; + onFormStatsAfterUpdate: Topic; + onFormStatsBeforeDelete: Topic; + onFormStatsAfterDelete: Topic; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsGetFormStatsParams { + where: { id: string; tenant: string; locale: string }; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsListFormStatsParams { + where: { formId: string; tenant: string; locale: string }; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsCreateFormStatsParams { + formStats: FbFormStats; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsUpdateFormStatsParams { + formStats: FbFormStats; +} + +/** + * @category StorageOperations + * @category StorageOperationsParams + */ +export interface FormBuilderStorageOperationsDeleteFormStatsParams { + ids: string[]; + tenant: string; + locale: string; +} + +/** + * @category StorageOperations + */ +export interface FormBuilderFormStatsStorageOperations { + getFormStats( + params: FormBuilderStorageOperationsGetFormStatsParams + ): Promise; + listFormStats( + params: FormBuilderStorageOperationsListFormStatsParams + ): Promise; + createFormStats( + params: FormBuilderStorageOperationsCreateFormStatsParams + ): Promise; + updateFormStats( + params: FormBuilderStorageOperationsUpdateFormStatsParams + ): Promise; + deleteFormStats(params: FormBuilderStorageOperationsDeleteFormStatsParams): Promise; } diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts index f36806427c6..6289f1b795e 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts @@ -927,79 +927,48 @@ export const createEntriesStorageOperations = ( const { entries } = params; const model = getStorageOperationsModel(initialModel); /** - * First we need all the revisions of the entries we want to delete. - */ - const revisions = await dataLoaders.getAllEntryRevisions({ - model, - ids: entries - }); - /** - * Then we need to construct the queries for all the revisions and entries. + * We need to construct the queries for all the revisions and entries. */ const items: Record[] = []; const esItems: Record[] = []; for (const id of entries) { - /** - * Latest item. - */ - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "L" - }) - ); - esItems.push( - esEntity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "L" - }) - ); - /** - * Published item. - */ + const partitionKey = createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }); + + const entryItems = await queryAll({ + entity, + partitionKey, + options: { + gte: " " + } + }); + + const esEntryItems = await queryAll({ + entity: esEntity, + partitionKey, + options: { + gte: " " + } + }); + items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "P" + ...entryItems.map(item => { + return entity.deleteBatch({ + PK: item.PK, + SK: item.SK + }); }) ); + esItems.push( - esEntity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "P" - }) - ); - } - /** - * Exact revisions of all the entries - */ - for (const revision of revisions) { - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id: revision.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey({ - version: revision.version - }) + ...esEntryItems.map(item => { + return esEntity.deleteBatch({ + PK: item.PK, + SK: item.SK + }); }) ); } diff --git a/packages/api-headless-cms/src/types.ts b/packages/api-headless-cms/src/types.ts index b6f743f42b3..0f2b97918d7 100644 --- a/packages/api-headless-cms/src/types.ts +++ b/packages/api-headless-cms/src/types.ts @@ -171,6 +171,7 @@ export type CmsModelFieldType = | "rich-text" | "text" | "dynamicZone" + | "json" | string; /** diff --git a/packages/api-page-builder-import-export/src/graphql/crud/forms.crud.ts b/packages/api-page-builder-import-export/src/graphql/crud/forms.crud.ts index 76bded0d5ce..fa7548537bc 100644 --- a/packages/api-page-builder-import-export/src/graphql/crud/forms.crud.ts +++ b/packages/api-page-builder-import-export/src/graphql/crud/forms.crud.ts @@ -96,7 +96,7 @@ export default new ContextPlugin(context => { ) { formIds = []; - const forms = await context.formBuilder.listForms(); + const [forms] = await context.formBuilder.listForms(); // Save form ids forms.forEach(form => formIds.push(form.id)); diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts index a2729d11c73..e9e0fd0b9f3 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts @@ -85,8 +85,6 @@ export const GET_FORM = gql` } settings ${SETTINGS_FIELDS} triggers - published - locked status } error ${ERROR_FIELDS} diff --git a/packages/app-form-builder/src/admin/graphql.ts b/packages/app-form-builder/src/admin/graphql.ts index c38b2678d62..b0b0e0d7f2d 100644 --- a/packages/app-form-builder/src/admin/graphql.ts +++ b/packages/app-form-builder/src/admin/graphql.ts @@ -5,6 +5,7 @@ import { FbFormSubmissionData, FbMetaResponse, FbRevisionModel, + FbFormOverallStats, FormBuilderImportExportSubTask } from "~/types"; @@ -18,7 +19,6 @@ const BASE_FORM_FIELDS = ` id name version - published status savedOn createdBy { @@ -116,11 +116,6 @@ export const GET_FORM = gql` form: getForm(revision: $revision) { data { ${BASE_FORM_FIELDS} - overallStats { - views - submissions - conversionRate - } } error { ${ERROR_FIELDS} @@ -160,6 +155,37 @@ export const GET_FORM_REVISIONS = gql` } `; +/** + * #################### + * Get Form Overall Stats Query + */ +export interface GetFormOverallStatsQueryResponse { + formBuilder: { + getFormOverallStats: { + data: FbFormOverallStats; + error: FbErrorResponse | null; + }; + }; +} +export interface GetFormOverallStatsQueryVariables { + id: string; +} +export const GET_FORM_OVERALL_STATS = gql` + query FbGetFormOverallStats($id: ID!) { + formBuilder { + getFormOverallStats(formId: $id) { + data { + views + submissions + } + error { + ${ERROR_FIELDS} + } + } + } + } +`; + /** * ############################ * List Form Submissions Query Response diff --git a/packages/app-form-builder/src/admin/plugins/editor/defaultBar/Name/Name.tsx b/packages/app-form-builder/src/admin/plugins/editor/defaultBar/Name/Name.tsx index 5e49c5260c8..4bd4309139b 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/defaultBar/Name/Name.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/defaultBar/Name/Name.tsx @@ -17,6 +17,8 @@ import { NameWrapper } from "./NameStyled"; import { i18n } from "@webiny/app/i18n"; +import { FORM_STATUS } from "~/types"; + const t = i18n.namespace("FormEditor.Name"); declare global { @@ -92,7 +94,7 @@ export const Name = () => { {`status: ${ - state.data.published ? t`published` : t`draft` + state.data.status === FORM_STATUS.PUBLISHED ? t`published` : t`draft` }`}
diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/formRevisions/Revision.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/formRevisions/Revision.tsx index bd1db10039a..ade6ddb0392 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/formRevisions/Revision.tsx +++ b/packages/app-form-builder/src/admin/plugins/formDetails/formRevisions/Revision.tsx @@ -24,7 +24,7 @@ import { ReactComponent as MoreVerticalIcon } from "../../../icons/more_vert.svg import { ReactComponent as PublishIcon } from "../../../icons/publish.svg"; import { ReactComponent as UnpublishIcon } from "../../../icons/unpublish.svg"; import { useRevision } from "./useRevision"; -import { FbFormModel, FbRevisionModel } from "~/types"; +import { FORM_STATUS, FbFormModel, FbRevisionModel } from "~/types"; import { usePermission } from "~/hooks/usePermission"; const primaryColor = css({ color: "var(--mdc-theme-primary)" }); @@ -37,12 +37,12 @@ const revisionsMenu = css({ const getIcon = (revision: Pick) => { switch (revision.status) { - case "locked": + case FORM_STATUS.UNPUBLISHED: return { icon: } />, text: "This revision is locked (it has already been published)" }; - case "published": + case FORM_STATUS.PUBLISHED: return { icon: } className={primaryColor} />, text: "This revision is currently published!" @@ -104,7 +104,7 @@ const Revision = (props: RevisionProps) => { New from current )} - {revision.status === "draft" && canUpdate(form) && ( + {revision.status === FORM_STATUS.DRAFT && canUpdate(form) && ( editRevision(revision.id)} data-testid={"fb.form-revisions.action-menu.edit"} @@ -116,7 +116,7 @@ const Revision = (props: RevisionProps) => { )} - {revision.status !== "published" && canPublish() && ( + {revision.status !== FORM_STATUS.PUBLISHED && canPublish() && ( publishRevision(revision.id)} data-testid={"fb.form-revisions.action-menu.publish"} @@ -128,7 +128,7 @@ const Revision = (props: RevisionProps) => { )} - {revision.status === "published" && canUnpublish() && ( + {revision.status === FORM_STATUS.PUBLISHED && canUnpublish() && ( { +const calculateConversionRate = (submissions: number, views: number) => { + return views > 0 ? parseFloat(((submissions / views) * 100).toFixed(2)) : 0; +}; + +export const FormSubmissionsOverview = ({ stats }: FormSubmissionsOverviewProps) => { + const conversionRate = calculateConversionRate(stats.submissions, stats.views); + return ( - {form.overallStats.submissions} + {stats.submissions} Submissions - {form.overallStats.views} + {stats.views} Views - {form.overallStats.conversionRate}% + {conversionRate}% Conversion Rate diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/index.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/index.tsx index 409497894f5..f1ad9856080 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/index.tsx +++ b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/index.tsx @@ -18,7 +18,7 @@ export default [ { name: "forms-form-details-revision-content-submissions", type: "forms-form-details-revision-content", - render({ form, loading, security }) { + render({ form, stats, loading, security }) { const { getPermissions } = security; const fbFormPermissions = getPermissions("fb.form"); @@ -49,8 +49,10 @@ export default [
{loading && } {form && + stats && renderPlugins("forms-form-details-submissions", { - form + form, + stats })}
@@ -61,8 +63,8 @@ export default [ { name: "forms-form-details-submissions-overview", type: "forms-form-details-submissions", - render({ form }) { - return ; + render({ stats }) { + return ; } } as FbFormDetailsSubmissionsPlugin, { diff --git a/packages/app-form-builder/src/admin/views/Forms/FormDetails.tsx b/packages/app-form-builder/src/admin/views/Forms/FormDetails.tsx index 05f820d95e5..58cfcd7f1a3 100644 --- a/packages/app-form-builder/src/admin/views/Forms/FormDetails.tsx +++ b/packages/app-form-builder/src/admin/views/Forms/FormDetails.tsx @@ -6,10 +6,13 @@ import styled from "@emotion/styled"; import { GET_FORM, GET_FORM_REVISIONS, + GET_FORM_OVERALL_STATS, GetFormRevisionQueryResponse, GetFormRevisionQueryVariables, GetFormRevisionsQueryResponse, - GetFormRevisionsQueryVariables + GetFormRevisionsQueryVariables, + GetFormOverallStatsQueryResponse, + GetFormOverallStatsQueryVariables } from "../../graphql"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { Tabs } from "@webiny/ui/Tabs"; @@ -105,6 +108,16 @@ const FormDetails = ({ onCreateForm }: FormDetailsProps) => { } ); + const getFormOverallStats = useQuery< + GetFormOverallStatsQueryResponse, + GetFormOverallStatsQueryVariables + >(GET_FORM_OVERALL_STATS, { + variables: { + id: formId || "" + }, + skip: !formId + }); + if (!formId) { return ; } @@ -114,6 +127,10 @@ const FormDetails = ({ onCreateForm }: FormDetailsProps) => { getRevisions.loading || !getRevisions.data ? [] : getRevisions.data.formBuilder.revisions.data; + const stats = + getFormOverallStats.loading || !getFormOverallStats.data + ? {} + : getFormOverallStats.data.formBuilder.getFormOverallStats.data; return ( @@ -122,7 +139,14 @@ const FormDetails = ({ onCreateForm }: FormDetailsProps) => { {renderPlugins( "forms-form-details-revision-content", - { security, refreshForms, form, revisions, loading: getForm.loading }, + { + security, + refreshForms, + form, + revisions, + stats, + loading: getForm.loading + }, { wrapper: false } )} diff --git a/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx b/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx index a4f0260fe43..81c21dd3ca4 100644 --- a/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx +++ b/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx @@ -45,7 +45,7 @@ import { ExportFormsButton } from "~/admin/plugins/formsDataList/ExportButton"; import { OptionsMenu } from "~/admin/components/OptionsMenu"; import { useForms } from "./useForms"; import { deserializeSorters } from "../utils"; -import { FbFormModel, FbRevisionModel } from "~/types"; +import { FORM_STATUS, FbFormModel, FbRevisionModel } from "~/types"; const t = i18n.namespace("FormsApp.FormsDataList"); const rightAlign = css({ @@ -138,7 +138,7 @@ const FormsDataList = (props: FormsDataListProps) => { const handlerKey = form.id + form.status; if (!editHandlers.current[handlerKey]) { editHandlers.current[handlerKey] = async () => { - if (!form.published) { + if (form.status !== FORM_STATUS.PUBLISHED) { history.push(`/form-builder/forms/${encodeURIComponent(form.id)}`); } diff --git a/packages/app-form-builder/src/page-builder/admin/plugins/components/FormElementAdvancedSettings.tsx b/packages/app-form-builder/src/page-builder/admin/plugins/components/FormElementAdvancedSettings.tsx index 4cfb76b6b9a..638bb954b85 100644 --- a/packages/app-form-builder/src/page-builder/admin/plugins/components/FormElementAdvancedSettings.tsx +++ b/packages/app-form-builder/src/page-builder/admin/plugins/components/FormElementAdvancedSettings.tsx @@ -1,8 +1,7 @@ import React, { useMemo } from "react"; -import { useLazyQuery, useQuery } from "@apollo/react-hooks"; +import { useQuery } from "@apollo/react-hooks"; import get from "lodash/get"; import { Grid, Cell } from "@webiny/ui/Grid"; -import { Alert } from "@webiny/ui/Alert"; import { AutoComplete } from "@webiny/ui/AutoComplete"; import styled from "@emotion/styled"; import { validation } from "@webiny/validation"; @@ -12,106 +11,41 @@ import { SimpleButton, classes } from "@webiny/app-page-builder/editor/plugins/elementSettings/components/StyledComponents"; -import { - LIST_FORMS, - GET_FORM_REVISIONS, - GetFormRevisionsQueryResponse, - GetFormRevisionsQueryVariables, - ListFormsQueryResponse -} from "./graphql"; +import { LIST_FORMS, ListFormsQueryResponse } from "./graphql"; import { BindComponent, FormOnSubmit } from "@webiny/form"; -import { FbRevisionModel } from "~/types"; const FormOptionsWrapper = styled("div")({ - minHeight: 250 + minHeight: 150 }); interface FormElementAdvancedSettingsProps { Bind: BindComponent; submit: FormOnSubmit; - data: Record; -} -interface RevisionsOutputOption { - name: string; - id: string; + data: Record; } -interface RevisionsOutput { - options: RevisionsOutputOption[]; - value: RevisionsOutputOption | null; -} -const FormElementAdvancedSettings = ({ Bind, submit, data }: FormElementAdvancedSettingsProps) => { - const listQuery = useQuery(LIST_FORMS, { fetchPolicy: "network-only" }); - const selectedForm = useMemo(() => { - return { - parent: get(data, "settings.form.parent"), - revision: get(data, "settings.form.revision") - }; - }, [data]); - - const [getFormRevisions, getQuery] = useLazyQuery< - GetFormRevisionsQueryResponse, - GetFormRevisionsQueryVariables - >(GET_FORM_REVISIONS, { - variables: { - id: selectedForm.parent - } +const FormElementAdvancedSettings = ({ Bind, submit, data }: FormElementAdvancedSettingsProps) => { + const listQuery = useQuery(LIST_FORMS, { + fetchPolicy: "network-only" }); - const latestRevisions = useMemo(() => { - const output: RevisionsOutput = { - options: [], - value: null - }; - if (listQuery.data) { - const latestFormRevisionsList = - (get( - listQuery, - "data.formBuilder.listForms.data" - ) as unknown as FbRevisionModel[]) || []; - - output.options = latestFormRevisionsList.map(({ id, name }) => ({ id, name })); - output.value = - output.options.find(item => { - if (typeof item.id !== "string" || typeof selectedForm.parent !== "string") { - return false; - } - // Get selected form's "baseId", i.e without the revision number suffix. - const [baseId] = selectedForm.parent.split("#"); - return item.id.includes(baseId); - }) || null; - } - - return output; - }, [listQuery, selectedForm]); + const publishedForms = listQuery?.data?.formBuilder.listForms.data || []; - const publishedRevisions = useMemo(() => { - const output: RevisionsOutput = { - options: [], - value: null - }; - - if (getQuery.data) { - const publishedRevisions = ( - get(getQuery, "data.formBuilder.getFormRevisions.data") as FbRevisionModel[] - ).filter(revision => revision.published); - output.options = publishedRevisions.map(item => ({ - id: item.id, - name: `${item.name} (version ${item.version})` - })); + const publishedFormsOptions = useMemo( + () => + publishedForms.map(publishedForm => ({ + id: publishedForm.id, + name: publishedForm.name + })), + [publishedForms] + ); - if (output.options.length > 0) { - output.options.unshift({ - id: "latest", - name: "Latest published revision" - }); - } + const selectedOption = useMemo(() => { + const formId = get(data, "settings.form.parent"); - output.value = output.options.find(item => item.id === selectedForm.revision) || null; - } + return publishedFormsOptions.find(option => option.id === formId); + }, [data, publishedFormsOptions]); - return output; - }, [getQuery, selectedForm]); // required so ts build does not break const buttonProps: any = {}; @@ -120,58 +54,23 @@ const FormElementAdvancedSettings = ({ Bind, submit, data }: FormElementAdvanced - + {({ onChange }) => ( { - onChange(value); - getFormRevisions(); + // For backward compatibility we always set revision "latest" and parent the actual formId + onChange({ + parent: value, + revision: "latest" + }); }} - label={"Form"} /> )} - - - {({ onChange }) => { - const parentSelected = !!latestRevisions.value; - const noPublished = publishedRevisions.options.length === 0; - if (getQuery.loading) { - return Loading revisions...; - } - - const description = "Choose a published revision."; - if (parentSelected && noPublished) { - return ( - - Please publish the form and then you can insert it into - your page. - - ); - } else { - return ( - - ); - } - }} - - diff --git a/packages/app-form-builder/src/page-builder/admin/plugins/components/graphql.ts b/packages/app-form-builder/src/page-builder/admin/plugins/components/graphql.ts index a38470b80db..aa3eda68157 100644 --- a/packages/app-form-builder/src/page-builder/admin/plugins/components/graphql.ts +++ b/packages/app-form-builder/src/page-builder/admin/plugins/components/graphql.ts @@ -52,7 +52,6 @@ export const GET_FORM_REVISIONS = gql` data { id name - published version } error { diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index 812d8d16932..36ffeb51b9f 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -187,7 +187,6 @@ export interface FbRevisionModel { id: string; name: string; version: number; - published: boolean; status: string; savedOn: string; createdBy: FbCreatedBy; @@ -198,6 +197,7 @@ export interface FbFormDetailsPluginRenderParams { security: Record; refreshForms: () => Promise; form: FbFormModel; + stats: FbFormOverallStats; revisions: FbRevisionModel[]; loading: boolean; } @@ -209,7 +209,7 @@ export type FbFormDetailsPluginType = Plugin & { export type FbFormDetailsSubmissionsPlugin = Plugin & { type: "forms-form-details-submissions"; - render: (props: { form: FbFormModel }) => React.ReactNode; + render: (props: { form: FbFormModel; stats: FbFormOverallStats }) => React.ReactNode; }; export interface FbFormModel { @@ -218,21 +218,20 @@ export interface FbFormModel { version: number; fields: FbFormModelField[]; steps: FbFormStep[]; - published: boolean; name: string; settings: any; status: string; savedOn: string; revisions: FbRevisionModel[]; - overallStats: { - submissions: number; - views: number; - conversionRate: number; - }; createdBy: FbCreatedBy; triggers: Record; } +export interface FbFormOverallStats { + views: number; + submissions: number; +} + export interface FbFormRenderModel extends Omit { fields: FormRenderFbFormModelField[]; } @@ -520,3 +519,9 @@ export interface FormBuilderImportExportSubTask { }; error: Record; } + +export enum FORM_STATUS { + DRAFT = "draft", + PUBLISHED = "published", + UNPUBLISHED = "unpublished" +} diff --git a/packages/app-page-builder-elements/src/renderers/form/types.ts b/packages/app-page-builder-elements/src/renderers/form/types.ts index 6fa597b554b..6cc51b6c028 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -37,7 +37,6 @@ export interface FormDataRevision { id: string; name: string; version: number; - published: boolean; status: string; savedOn: string; createdBy: FormDataCreatedBy; @@ -55,17 +54,11 @@ export interface FormData { version: number; fields: FormDataField[]; steps: FormDataStep[]; - published: boolean; name: string; settings: any; status: string; savedOn: string; revisions: FormDataRevision[]; - overallStats: { - submissions: number; - views: number; - conversionRate: number; - }; createdBy: FormDataCreatedBy; triggers: Record; } diff --git a/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/export/combine/src/index.ts b/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/export/combine/src/index.ts index 382770a84b7..7cfe8de0861 100644 --- a/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/export/combine/src/index.ts +++ b/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/export/combine/src/index.ts @@ -2,6 +2,8 @@ import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb-es"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb-es"; import { @@ -43,6 +45,18 @@ export const handler = createHandler({ securityPlugins({ documentClient }), i18nPlugins(), i18nDynamoDbStorageOperations(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient, diff --git a/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/import/create/src/index.ts b/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/import/create/src/index.ts index f80e0ca7040..1f187cdca92 100644 --- a/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/import/create/src/index.ts +++ b/packages/serverless-cms-aws/handlers/ddb-es/api/pageBuilder/import/create/src/index.ts @@ -2,6 +2,8 @@ import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb-es"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb-es"; import { @@ -45,6 +47,18 @@ export const handler = createHandler({ securityPlugins({ documentClient }), i18nPlugins(), i18nDynamoDbStorageOperations(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient, diff --git a/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/export/combine/src/index.ts b/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/export/combine/src/index.ts index 7786a90ac42..475c0d3cfb4 100644 --- a/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/export/combine/src/index.ts +++ b/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/export/combine/src/index.ts @@ -3,6 +3,8 @@ import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; import i18nContentPlugins from "@webiny/api-i18n-content/plugins"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { @@ -37,6 +39,18 @@ export const handler = createHandler({ i18nPlugins(), i18nDynamoDbStorageOperations(), i18nContentPlugins(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient diff --git a/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/import/create/src/index.ts b/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/import/create/src/index.ts index 5c2c531834d..7959e811f0b 100644 --- a/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/import/create/src/index.ts +++ b/packages/serverless-cms-aws/handlers/ddb/api/pageBuilder/import/create/src/index.ts @@ -3,6 +3,8 @@ import { createHandler } from "@webiny/handler-aws/raw"; import i18nPlugins from "@webiny/api-i18n/graphql"; import i18nDynamoDbStorageOperations from "@webiny/api-i18n-ddb"; import i18nContentPlugins from "@webiny/api-i18n-content/plugins"; +import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; +import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { @@ -35,6 +37,18 @@ export const handler = createHandler({ i18nPlugins(), i18nDynamoDbStorageOperations(), i18nContentPlugins(), + new CmsParametersPlugin(async context => { + const locale = context.i18n.getCurrentLocale("content")?.code || "en-US"; + return { + type: "manage", + locale + }; + }), + createHeadlessCmsContext({ + storageOperations: createHeadlessCmsStorageOperations({ + documentClient + }) + }), createPageBuilderContext({ storageOperations: createPageBuilderStorageOperations({ documentClient diff --git a/yarn.lock b/yarn.lock index 6db14978144..bd39e4b0dd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14395,7 +14395,6 @@ __metadata: "@webiny/handler-db": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 - "@webiny/utils": 0.0.0 csvtojson: ^2.0.10 elastic-ts: ^0.8.0 jest: ^29.7.0 @@ -14421,9 +14420,7 @@ __metadata: "@webiny/db-dynamodb": 0.0.0 "@webiny/error": 0.0.0 "@webiny/handler-db": 0.0.0 - "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 - "@webiny/utils": 0.0.0 csvtojson: ^2.0.10 jest: ^29.7.0 jest-dynalite: ^3.2.0 @@ -14442,7 +14439,6 @@ __metadata: "@babel/preset-env": ^7.23.9 "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.23.9 - "@commodo/fields": 1.1.2-beta.20 "@types/got": ^9.6.12 "@types/json2csv": ^4.5.1 "@types/node-fetch": ^2.6.1 @@ -14465,7 +14461,6 @@ __metadata: "@webiny/pubsub": 0.0.0 "@webiny/utils": 0.0.0 "@webiny/validation": 0.0.0 - commodo-fields-object: ^1.0.6 csvtojson: ^2.0.10 date-fns: ^2.22.1 fs-extra: ^9.1.0 @@ -14479,6 +14474,7 @@ __metadata: slugify: ^1.2.9 ttypescript: ^1.5.12 typescript: 4.7.4 + zod: ^3.21.4 languageName: unknown linkType: soft @@ -19021,6 +19017,8 @@ __metadata: dependencies: "@webiny/api-form-builder": 0.0.0 "@webiny/api-form-builder-so-ddb": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-headless-cms-ddb": 0.0.0 "@webiny/api-i18n": 0.0.0 "@webiny/api-i18n-content": 0.0.0 "@webiny/api-i18n-ddb": 0.0.0 @@ -19080,6 +19078,8 @@ __metadata: dependencies: "@webiny/api-form-builder": 0.0.0 "@webiny/api-form-builder-so-ddb": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-headless-cms-ddb": 0.0.0 "@webiny/api-i18n": 0.0.0 "@webiny/api-i18n-content": 0.0.0 "@webiny/api-i18n-ddb": 0.0.0 @@ -42731,7 +42731,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4": +"zod@npm:^3.21.4, zod@npm:^3.22.4": version: 3.22.4 resolution: "zod@npm:3.22.4" checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f