diff --git a/apps/admin/src/App.auth0.tsx b/apps/admin/src/App.auth0.tsx index 15b9bded727..487edda001d 100644 --- a/apps/admin/src/App.auth0.tsx +++ b/apps/admin/src/App.auth0.tsx @@ -8,11 +8,22 @@ export const App = () => { return ( { + const query = new URLSearchParams(window.location.search); + return query.get("action") !== "logout"; + }} auth0={{ domain: String(process.env.REACT_APP_AUTH0_DOMAIN), clientId: String(process.env.REACT_APP_AUTH0_CLIENT_ID) }} rootAppClientId={String(process.env.REACT_APP_AUTH0_CLIENT_ID)} + onLogout={logout => { + logout({ + logoutParams: { + returnTo: window.location.origin + "?action=logout" + } + }); + }} /> diff --git a/apps/api/graphql/src/security.auth0.ts b/apps/api/graphql/src/security.auth0.ts index 607974066d4..443d7ea5c9c 100644 --- a/apps/api/graphql/src/security.auth0.ts +++ b/apps/api/graphql/src/security.auth0.ts @@ -78,7 +78,7 @@ export default ({ documentClient }: { documentClient: DynamoDBDocument }) => [ id: token["sub"], type: "admin", displayName: token["name"], - group: token["webiny_group"] + group: "full-access" }; } }), diff --git a/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts b/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts index 587315cbefc..0e44f25d5f3 100644 --- a/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts +++ b/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts @@ -17,7 +17,7 @@ type FilterModelMethods = { * E.g., `getEntryManager` has `model` typed as `CmsModel | string`. * Ideally, we would filter those out in the previous utility type, but I'm not sure how to achieve that. */ -type ModelMethods = Omit, "getEntryManager">; +type ModelMethods = Omit, "getEntryManager" | "getSingletonEntryManager">; /** * Decorator takes the decoratee as the _first_ parameter, and then forwards the rest of the parameters. diff --git a/packages/api-elasticsearch/src/plugins/operator/equal.ts b/packages/api-elasticsearch/src/plugins/operator/equal.ts index 8aaed119139..879e8db291b 100644 --- a/packages/api-elasticsearch/src/plugins/operator/equal.ts +++ b/packages/api-elasticsearch/src/plugins/operator/equal.ts @@ -14,7 +14,7 @@ export class ElasticsearchQueryBuilderOperatorEqualPlugin extends ElasticsearchQ ): void { const { value, path, basePath } = params; - if (value === null) { + if (value === null || value === undefined) { query.must_not.push({ exists: { field: path diff --git a/packages/api-elasticsearch/src/plugins/operator/not.ts b/packages/api-elasticsearch/src/plugins/operator/not.ts index dc5a7523f23..1e918b8de5f 100644 --- a/packages/api-elasticsearch/src/plugins/operator/not.ts +++ b/packages/api-elasticsearch/src/plugins/operator/not.ts @@ -14,7 +14,7 @@ export class ElasticsearchQueryBuilderOperatorNotPlugin extends ElasticsearchQue ): void { const { value, path, basePath } = params; - if (value === null) { + if (value === null || value === undefined) { query.filter.push({ exists: { field: path diff --git a/packages/api-elasticsearch/src/sort.ts b/packages/api-elasticsearch/src/sort.ts index 7080c3bf069..ff32ecf6f2c 100644 --- a/packages/api-elasticsearch/src/sort.ts +++ b/packages/api-elasticsearch/src/sort.ts @@ -1,5 +1,5 @@ import WebinyError from "@webiny/error"; -import { FieldSortOptions, SortType, SortOrder } from "~/types"; +import { FieldSortOptions, SortOrder, SortType } from "~/types"; import { ElasticsearchFieldPlugin } from "~/plugins"; const sortRegExp = new RegExp(/^([a-zA-Z-0-9_@]+)_(ASC|DESC)$/); @@ -31,7 +31,7 @@ export const createSort = (params: CreateSortParams): SortType => { /** * Cast as string because nothing else should be allowed yet. */ - return sort.reduce((acc, value) => { + const result = sort.reduce((acc, value) => { if (typeof value !== "string") { throw new WebinyError(`Sort as object is not supported..`); } @@ -61,4 +61,13 @@ export const createSort = (params: CreateSortParams): SortType => { return acc; }, {} as Record); + /** + * If we do not have id in the sort, we add it as we need a tie_breaker for the Elasticsearch to be able to sort consistently. + */ + if (!result["id.keyword"] && !result["id"]) { + result["id.keyword"] = { + order: "asc" + }; + } + return result; }; diff --git a/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts index 861307d8e11..90945d7c62d 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts @@ -15,6 +15,7 @@ export type AssetDeliveryParams = Parameters[0] * @see https://repost.aws/knowledge-center/presigned-url-s3-bucket-expiration */ presignedUrlTtl?: number; + assetStreamingMaxSize?: number; }; export const assetDeliveryConfig = (params: AssetDeliveryParams) => { @@ -25,6 +26,11 @@ export const assetDeliveryConfig = (params: AssetDeliveryParams) => { // Presigned URLs last 1 hour presignedUrlTtl = 3600, imageResizeWidths = [100, 300, 500, 750, 1000, 1500, 2500], + /** + * Even though Lambda's response payload limit is 6,291,556 bytes, we leave some room for the response envelope. + * We had situations where a 4.7MB file would cause the payload to go over the limit, so let's be on the safe side. + */ + assetStreamingMaxSize = 4718592, ...baseParams } = params; @@ -41,7 +47,7 @@ export const assetDeliveryConfig = (params: AssetDeliveryParams) => { }); config.decorateAssetOutputStrategy(() => { - return new S3OutputStrategy(s3, bucket, presignedUrlTtl); + return new S3OutputStrategy(s3, bucket, presignedUrlTtl, assetStreamingMaxSize); }); config.decorateAssetTransformationStrategy(() => { diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts index ea574101aed..894cfd33f85 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts @@ -3,8 +3,6 @@ import { GetObjectCommand, getSignedUrl, S3 } from "@webiny/aws-sdk/client-s3"; import { S3RedirectAssetReply } from "~/assetDelivery/s3/S3RedirectAssetReply"; import { S3StreamAssetReply } from "~/assetDelivery/s3/S3StreamAssetReply"; -const MAX_RETURN_CONTENT_LENGTH = 4915200; // ~4.8 MB - /** * This strategy outputs an asset taking into account the size of the asset contents. * If the asset is larger than 5MB, a presigned URL will be generated, and a redirect will happen. @@ -13,21 +11,30 @@ export class S3OutputStrategy implements AssetOutputStrategy { private readonly s3: S3; private readonly bucket: string; private readonly presignedUrlTtl: number; + private readonly assetStreamingMaxSize: number; - constructor(s3: S3, bucket: string, presignedUrlTtl: number) { + constructor(s3: S3, bucket: string, presignedUrlTtl: number, assetStreamingMaxSize: number) { + this.assetStreamingMaxSize = assetStreamingMaxSize; this.presignedUrlTtl = presignedUrlTtl; this.s3 = s3; this.bucket = bucket; } async output(asset: Asset): Promise { - if ((await asset.getSize()) > MAX_RETURN_CONTENT_LENGTH) { + if (asset.getSize() > this.assetStreamingMaxSize) { + console.log( + `Asset size is greater than ${this.assetStreamingMaxSize}; redirecting to a presigned S3 URL.` + ); + return new S3RedirectAssetReply( await this.getPresignedUrl(asset), this.presignedUrlTtl ); } + console.log( + `Asset size is smaller than ${this.assetStreamingMaxSize}; streaming directly from Lambda function.` + ); return new S3StreamAssetReply(asset); } diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts index 6bad750b5a0..7f833376395 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts @@ -26,6 +26,9 @@ export class SharpTransform implements AssetTransformationStrategy { async transform(assetRequest: AssetRequest, asset: Asset): Promise { if (!utils.SUPPORTED_TRANSFORMABLE_IMAGES.includes(asset.getExtension())) { + console.log( + `Transformations/optimizations of ${asset.getContentType()} assets are not supported. Skipping.` + ); return asset; } @@ -45,6 +48,7 @@ export class SharpTransform implements AssetTransformationStrategy { } private async transformAsset(asset: Asset, options: Omit) { + console.log("Transform asset", options); if (options.width) { const { s3, bucket } = this.params; @@ -63,7 +67,15 @@ export class SharpTransform implements AssetTransformationStrategy { const buffer = Buffer.from(await Body.transformToByteArray()); - asset.setContentsReader(new CallableContentsReader(() => buffer)); + const newAsset = asset.withProps({ size: buffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => buffer)); + + console.log(`Return a previously transformed asset`, { + key: transformedAssetKey, + size: newAsset.getSize() + }); + + return newAsset; } catch (e) { const optimizedImage = await this.optimizeAsset(asset); @@ -73,8 +85,11 @@ export class SharpTransform implements AssetTransformationStrategy { /** * `width` is the only transformation we currently support. */ + console.log(`Resize the asset (width: ${width})`); const buffer = await optimizedImage.getContents(); - const transformedBuffer = sharp(buffer, { animated: this.isAssetAnimated(asset) }) + const transformedBuffer = await sharp(buffer, { + animated: this.isAssetAnimated(asset) + }) .withMetadata() .resize({ width, withoutEnlargement: true }) .toBuffer(); @@ -82,14 +97,22 @@ export class SharpTransform implements AssetTransformationStrategy { /** * Transformations are applied to the optimized image. */ - asset.setContentsReader(new CallableContentsReader(() => transformedBuffer)); + const newAsset = asset.withProps({ size: transformedBuffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => transformedBuffer)); await s3.putObject({ Bucket: bucket, Key: transformedAssetKey, - ContentType: asset.getContentType(), - Body: await asset.getContents() + ContentType: newAsset.getContentType(), + Body: await newAsset.getContents() }); + + console.log(`Return the resized asset`, { + key: transformedAssetKey, + size: newAsset.getSize() + }); + + return newAsset; } } @@ -99,6 +122,13 @@ export class SharpTransform implements AssetTransformationStrategy { private async optimizeAsset(asset: Asset) { const { s3, bucket } = this.params; + console.log("Optimize asset", { + id: asset.getId(), + key: asset.getKey(), + size: asset.getSize(), + type: asset.getContentType() + }); + const assetKey = new AssetKeyGenerator(asset); const optimizedAssetKey = assetKey.getOptimizedImageKey(); @@ -112,10 +142,16 @@ export class SharpTransform implements AssetTransformationStrategy { throw new Error(`Missing image body!`); } + console.log("Return a previously optimized asset", optimizedAssetKey); + const buffer = Buffer.from(await Body.transformToByteArray()); - asset.setContentsReader(new CallableContentsReader(() => buffer)); + const newAsset = asset.withProps({ size: buffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => buffer)); + + return newAsset; } catch (e) { + console.log("Create an optimized version of the original asset", asset.getKey()); // If not found, create an optimized version of the original asset. const buffer = await asset.getContents(); @@ -128,23 +164,26 @@ export class SharpTransform implements AssetTransformationStrategy { const optimization = optimizationMap[asset.getContentType()]; if (!optimization) { - console.log(`no optimizations defined for ${asset.getContentType()}`); + console.log(`No optimizations defined for ${asset.getContentType()}`); return asset; } - const optimizedBuffer = optimization(buffer).toBuffer(); + const optimizedBuffer = await optimization(buffer).toBuffer(); + + console.log("Optimized asset size", optimizedBuffer.length); - asset.setContentsReader(new CallableContentsReader(() => optimizedBuffer)); + const newAsset = asset.withProps({ size: optimizedBuffer.length }); + newAsset.setContentsReader(new CallableContentsReader(() => optimizedBuffer)); await s3.putObject({ Bucket: bucket, Key: optimizedAssetKey, - ContentType: asset.getContentType(), - Body: await asset.getContents() + ContentType: newAsset.getContentType(), + Body: await newAsset.getContents() }); - } - return asset; + return newAsset; + } } private isAssetAnimated(asset: Asset) { diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts index ed209ee8470..44a2daf379a 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts @@ -21,10 +21,14 @@ export class Asset { } clone() { - const clonedAsset = new Asset(structuredClone(this.props)); - clonedAsset.outputStrategy = this.outputStrategy; - clonedAsset.contentsReader = this.contentsReader; - return clonedAsset; + return this.withProps(structuredClone(this.props)); + } + + withProps(props: Partial) { + const newAsset = new Asset({ ...this.props, ...props }); + newAsset.contentsReader = this.contentsReader; + newAsset.outputStrategy = this.outputStrategy; + return newAsset; } getId() { @@ -39,9 +43,8 @@ export class Asset { getKey() { return this.props.key; } - async getSize() { - const buffer = await this.getContents(); - return buffer.length; + getSize() { + return this.props.size; } getContentType() { return this.props.contentType; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts index b0b17f62685..5ef161179f8 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts @@ -1,6 +1,6 @@ import { Request } from "@webiny/handler/types"; import { AssetRequestResolver } from "./abstractions/AssetRequestResolver"; -import { AssetRequest } from "./AssetRequest"; +import { AssetRequest, AssetRequestOptions } from "./AssetRequest"; export class FilesAssetRequestResolver implements AssetRequestResolver { async resolve(request: Request): Promise { @@ -15,15 +15,21 @@ export class FilesAssetRequestResolver implements AssetRequestResolver { // Example: { '*': '/files/65722cb5c7824a0008d05963/image-48.jpg' }, const path = params["*"]; + const options: AssetRequestOptions = { + ...query, + original: "original" in query + }; + + if (query.width) { + options.width = parseInt(query.width); + } + return new AssetRequest({ key: decodeURI(path).replace("/files/", ""), context: { url: request.url }, - options: { - ...query, - width: query.width ? parseInt(query.width) : undefined - } + options }); } } diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts index fbd330330a7..39d17f83d52 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts @@ -12,6 +12,7 @@ export class TransformationAssetProcessor implements AssetProcessor { // If the `original` image was requested, we skip all transformations. if (original) { + console.log("Skip transformations; original asset was requested."); return asset; } diff --git a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts index f30749f5083..371f20e3c25 100644 --- a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts +++ b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts @@ -146,6 +146,7 @@ export const setupAssetDelivery = (params: AssetDeliveryParams) => { ); // Get reply object (runs the output strategy under the hood). + console.log(`Output asset (size: ${processedAsset.getSize()} bytes).`); return outputAsset(reply, processedAsset); }, { override: true } diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/plugins/refFilterPlugin.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/plugins/refFilterPlugin.ts index 1050e9adbf1..3ebea2f5527 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/plugins/refFilterPlugin.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/plugins/refFilterPlugin.ts @@ -3,10 +3,12 @@ import { CmsEntryFilterPlugin } from "~/plugins/CmsEntryFilterPlugin"; import { parseWhereKey } from "@webiny/api-elasticsearch"; export const createRefFilterPlugin = () => { - return new CmsEntryFilterPlugin({ + const plugin = new CmsEntryFilterPlugin({ fieldType: "ref", exec: params => { - const { applyFiltering, value: values, query, field } = params; + const { applyFiltering, query, field } = params; + + let values = params.value; /** * We must have an object when querying in the ref field. */ @@ -20,6 +22,12 @@ export const createRefFilterPlugin = () => { ); } + if (values === null || values === undefined) { + values = { + entryId: null + }; + } + for (const key in values) { const { operator } = parseWhereKey(key); const value = values[key]; @@ -37,4 +45,8 @@ export const createRefFilterPlugin = () => { } } }); + + plugin.name = `${plugin.type}.default.ref`; + + return plugin; }; diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts index 67b5cd28260..6a9408e913a 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/sort.ts @@ -17,7 +17,13 @@ export const createElasticsearchSort = (params: Params): esSort => { const { sort, modelFields, plugins } = params; if (!sort || sort.length === 0) { - return []; + return [ + { + ["id.keyword"]: { + order: "asc" + } + } + ]; } const searchPlugins = createSearchPluginList({ 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 93344d11311..574423f8119 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 @@ -2,6 +2,7 @@ import WebinyError from "@webiny/error"; import { CmsEntry, CmsModel, + CmsStorageEntry, CONTENT_ENTRY_STATUS, StorageOperationsCmsModel } from "@webiny/api-headless-cms/types"; @@ -67,6 +68,24 @@ export interface CreateEntriesStorageOperationsParams { plugins: PluginsContainer; } +interface ConvertStorageEntryParams { + storageEntry: CmsStorageEntry; + model: StorageOperationsCmsModel; +} + +const convertToStorageEntry = (params: ConvertStorageEntryParams): CmsStorageEntry => { + const { model, storageEntry } = params; + + const values = model.convertValueKeyToStorage({ + fields: model.fields, + values: storageEntry.values + }); + return { + ...storageEntry, + values + }; +}; + export const createEntriesStorageOperations = ( params: CreateEntriesStorageOperationsParams ): CmsEntryStorageOperations => { @@ -297,6 +316,30 @@ export const createEntriesStorageOperations = ( ...publishedKeys }) ); + + // Unpublish previously published revision (if any). + const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId( + { + model, + ids: [entry.id] + } + ); + + if (publishedRevisionStorageEntry) { + items.push( + entity.putBatch({ + ...publishedRevisionStorageEntry, + PK: createPartitionKey({ + id: publishedRevisionStorageEntry.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey(publishedRevisionStorageEntry), + TYPE: createRecordType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED + }) + ); + } } try { @@ -1169,7 +1212,7 @@ export const createEntriesStorageOperations = ( initialModel, params ) => { - const { entry, latestEntry, latestStorageEntry } = params; + const { entry, latestEntry, latestStorageEntry: initialLatestStorageEntry } = params; const model = getStorageOperationsModel(initialModel); const partitionKey = createPartitionKey({ @@ -1214,14 +1257,19 @@ export const createEntriesStorageOperations = ( }) ); esItems.push( - entity.deleteBatch({ + esEntity.deleteBatch({ PK: partitionKey, SK: createPublishedSortKey() }) ); } - if (latestEntry && latestStorageEntry) { + if (latestEntry && initialLatestStorageEntry) { + const latestStorageEntry = convertToStorageEntry({ + storageEntry: initialLatestStorageEntry, + model + }); + /** * In the end we need to set the new latest entry. */ @@ -1242,11 +1290,11 @@ export const createEntriesStorageOperations = ( entity.putBatch({ ...latestStorageEntry, PK: createPartitionKey({ - id: latestStorageEntry.id, + id: initialLatestStorageEntry.id, locale: model.locale, tenant: model.tenant }), - SK: createRevisionSortKey(latestStorageEntry), + SK: createRevisionSortKey(initialLatestStorageEntry), TYPE: createRecordType() }) ); @@ -1255,7 +1303,7 @@ export const createEntriesStorageOperations = ( plugins, model, entry: latestEntry, - storageEntry: latestStorageEntry + storageEntry: initialLatestStorageEntry }); const esLatestData = await latestTransformer.getElasticsearchLatestEntryData(); @@ -1286,7 +1334,7 @@ export const createEntriesStorageOperations = ( error: ex, entry, latestEntry, - latestStorageEntry + initialLatestStorageEntry } ); } @@ -1309,7 +1357,7 @@ export const createEntriesStorageOperations = ( error: ex, entry, latestEntry, - latestStorageEntry + initialLatestStorageEntry } ); } @@ -1517,14 +1565,6 @@ export const createEntriesStorageOperations = ( const { entry, storageEntry } = transformer.transformEntryKeys(); - /** - * We need currently published entry to check if need to remove it. - */ - const [publishedStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId({ - model, - ids: [entry.id] - }); - const revisionKeys = { PK: createPartitionKey({ id: entry.id, @@ -1568,175 +1608,158 @@ export const createEntriesStorageOperations = ( ); } - const items = [ - entity.putBatch({ - ...storageEntry, - ...revisionKeys, - TYPE: createRecordType() - }) - ]; - const esItems: BatchWriteItem[] = []; + if (!latestEsEntry) { + throw new WebinyError( + `Could not publish entry. Could not load latest ("L") record (ES table).`, + "PUBLISH_ERROR", + { entry } + ); + } - const { index: esIndex } = configurations.es({ - model + /** + * We need the latest entry to check if it needs to be updated as well in the Elasticsearch. + */ + const [latestStorageEntry] = await dataLoaders.getLatestRevisionByEntryId({ + model, + ids: [entry.id] }); - if (publishedStorageEntry && publishedStorageEntry.id !== entry.id) { - /** - * If there is a `published` entry already, we need to set it to `unpublished`. We need to - * execute two updates: update the previously published entry's status and the published entry record. - * DynamoDB does not support `batchUpdate` - so here we load the previously published - * entry's data to update its status within a batch operation. If, hopefully, - * they introduce a true update batch operation, remove this `read` call. - */ - const [previouslyPublishedEntry] = await dataLoaders.getRevisionById({ - model, - ids: [publishedStorageEntry.id] - }); - items.push( - /** - * Update currently published entry (unpublish it) - */ - entity.putBatch({ - ...previouslyPublishedEntry, - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - TYPE: createRecordType(), - PK: createPartitionKey(publishedStorageEntry), - SK: createRevisionSortKey(publishedStorageEntry) - }) + if (!latestStorageEntry) { + throw new WebinyError( + `Could not publish entry. Could not load latest ("L") record.`, + "PUBLISH_ERROR", + { entry } ); } + /** - * Update the helper item in DB with the new published entry + * We need currently published entry to check if need to remove it. */ - items.push( + const [publishedStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId({ + model, + ids: [entry.id] + }); + + // 1. Update REV# and P records with new data. + const items = [ + entity.putBatch({ + ...storageEntry, + ...revisionKeys, + TYPE: createRecordType() + }), entity.putBatch({ ...storageEntry, ...publishedKeys, TYPE: createPublishedRecordType() }) - ); + ]; + const esItems: BatchWriteItem[] = []; - /** - * We need the latest entry to check if it needs to be updated as well in the Elasticsearch. - */ - const [latestStorageEntry] = await dataLoaders.getLatestRevisionByEntryId({ - model, - ids: [entry.id] + const { index: esIndex } = configurations.es({ + model }); - if (latestStorageEntry?.id === entry.id) { + // 2. When it comes to the latest record, we need to perform a couple of different + // updates, based on whether the entry being published is the latest revision or not. + const publishedRevisionId = publishedStorageEntry?.id; + const publishingLatestRevision = latestStorageEntry?.id === entry.id; + + if (publishingLatestRevision) { + // 2.1 If we're publishing the latest revision, we first need to update the L record. items.push( entity.putBatch({ ...storageEntry, ...latestKeys }) ); - } - - if (latestEsEntry) { - const publishingLatestRevision = latestStorageEntry?.id === entry.id; - - /** - * Need to decompress the data from Elasticsearch DynamoDB table. - * - * No need to transform it for the storage because it was fetched - * directly from the Elasticsearch table, where it sits transformed. - */ - const latestEsEntryDataDecompressed = (await decompress( - plugins, - latestEsEntry.data - )) as CmsIndexEntry; - - if (publishingLatestRevision) { - const updatedMetaFields = pickEntryMetaFields(entry); - - const latestTransformer = createTransformer({ - plugins, - model, - transformedToIndex: { - ...latestEsEntryDataDecompressed, - status: CONTENT_ENTRY_STATUS.PUBLISHED, - locked: true, - ...updatedMetaFields - } - }); - - esItems.push( - esEntity.putBatch({ - index: esIndex, - PK: createPartitionKey(latestEsEntryDataDecompressed), - SK: createLatestSortKey(), - data: await latestTransformer.getElasticsearchLatestEntryData() - }) - ); - } else { - const updatedEntryLevelMetaFields = pickEntryMetaFields( - entry, - isEntryLevelEntryMetaField - ); - - const updatedLatestStorageEntry = { - ...latestStorageEntry, - ...latestKeys, - ...updatedEntryLevelMetaFields - }; - /** - * First we update the regular DynamoDB table. Two updates are needed: - * - one for the actual revision record - * - one for the latest record - */ - items.push( - entity.putBatch({ - ...updatedLatestStorageEntry, - PK: createPartitionKey({ - id: latestStorageEntry.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createRecordType() - }) - ); + // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished. + // Note that we need to take re-publishing into account (same published revision being + // published again), in which case the below code does not apply. This is because the + // required updates were already applied above. + if (publishedStorageEntry) { + const isRepublishing = publishedStorageEntry.id === entry.id; + if (!isRepublishing) { + items.push( + /** + * Update currently published entry (unpublish it) + */ + entity.putBatch({ + ...publishedStorageEntry, + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + TYPE: createRecordType(), + PK: createPartitionKey(publishedStorageEntry), + SK: createRevisionSortKey(publishedStorageEntry) + }) + ); + } + } + } else { + // 2.3 If the published revision is not the latest one, the situation is a bit + // more complex. We first need to update the L and REV# records with the new + // values of *only entry-level* meta fields. + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); - items.push( - entity.putBatch({ - ...updatedLatestStorageEntry, - TYPE: createLatestRecordType() - }) - ); + // 2.4 Update L record. Apart from updating the entry-level meta fields, we also need + // to change the status from "published" to "unpublished" (if the status is set to "published"). + let latestRevisionStatus = latestStorageEntry.status; + if (latestRevisionStatus === CONTENT_ENTRY_STATUS.PUBLISHED) { + latestRevisionStatus = CONTENT_ENTRY_STATUS.UNPUBLISHED; + } - /** - * Update the Elasticsearch table to propagate changes to the Elasticsearch. - */ - const latestEsEntry = await getClean({ - entity: esEntity, - keys: latestKeys - }); + const latestStorageEntryFields = { + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + status: latestRevisionStatus + }; - if (latestEsEntry) { - const latestEsEntryDataDecompressed = (await decompress( - plugins, - latestEsEntry.data - )) as CmsIndexEntry; + items.push( + entity.putBatch({ + ...latestStorageEntryFields, + PK: createPartitionKey(latestStorageEntry), + SK: createLatestSortKey(), + TYPE: createLatestRecordType() + }) + ); - const updatedLatestEntry = await compress(plugins, { - ...latestEsEntryDataDecompressed, - ...updatedEntryLevelMetaFields - }); + // 2.5 Update REV# record. + items.push( + entity.putBatch({ + ...latestStorageEntryFields, + PK: createPartitionKey(latestStorageEntry), + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createRecordType() + }) + ); - esItems.push( - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: updatedLatestEntry + // 2.6 Additionally, if we have a previously published entry, we need to mark it as unpublished. + // Note that we need to take re-publishing into account (same published revision being + // published again), in which case the below code does not apply. This is because the + // required updates were already applied above. + if (publishedStorageEntry) { + const isRepublishing = publishedStorageEntry.id === entry.id; + const publishedRevisionDifferentFromLatest = + publishedRevisionId !== latestStorageEntry.id; + + if (!isRepublishing && publishedRevisionDifferentFromLatest) { + items.push( + entity.putBatch({ + ...publishedStorageEntry, + PK: createPartitionKey(publishedStorageEntry), + SK: createRevisionSortKey(publishedStorageEntry), + TYPE: createRecordType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED }) ); } } } + // 3. Update records in ES -> DDB table. + /** * Update the published revision entry in ES. */ @@ -1749,6 +1772,80 @@ export const createEntriesStorageOperations = ( }) ); + /** + * Need to decompress the data from Elasticsearch DynamoDB table. + * + * No need to transform it for the storage because it was fetched + * directly from the Elasticsearch table, where it sits transformed. + */ + const latestEsEntryDataDecompressed = (await decompress( + plugins, + latestEsEntry.data + )) as CmsIndexEntry; + + if (publishingLatestRevision) { + const updatedMetaFields = pickEntryMetaFields(entry); + + const latestTransformer = createTransformer({ + plugins, + model, + transformedToIndex: { + ...latestEsEntryDataDecompressed, + status: CONTENT_ENTRY_STATUS.PUBLISHED, + locked: true, + ...updatedMetaFields + } + }); + + esItems.push( + esEntity.putBatch({ + index: esIndex, + PK: createPartitionKey(latestEsEntryDataDecompressed), + SK: createLatestSortKey(), + data: await latestTransformer.getElasticsearchLatestEntryData() + }) + ); + } else { + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); + + /** + * Update the Elasticsearch table to propagate changes to the Elasticsearch. + */ + const latestEsEntry = await getClean({ + entity: esEntity, + keys: latestKeys + }); + + if (latestEsEntry) { + const latestEsEntryDataDecompressed = (await decompress( + plugins, + latestEsEntry.data + )) as CmsIndexEntry; + + let latestRevisionStatus = latestEsEntryDataDecompressed.status; + if (latestRevisionStatus === CONTENT_ENTRY_STATUS.PUBLISHED) { + latestRevisionStatus = CONTENT_ENTRY_STATUS.UNPUBLISHED; + } + + const updatedLatestEntry = await compress(plugins, { + ...latestEsEntryDataDecompressed, + ...updatedEntryLevelMetaFields, + status: latestRevisionStatus + }); + + esItems.push( + esEntity.putBatch({ + ...latestKeys, + index: esIndex, + data: updatedLatestEntry + }) + ); + } + } + /** * Finally, execute regular table batch. */ diff --git a/packages/api-headless-cms-ddb/__tests__/plugins/dynamoDb/transformValue/datetime.test.ts b/packages/api-headless-cms-ddb/__tests__/plugins/dynamoDb/transformValue/datetime.test.ts index 46e16a38ec3..1c4463cf51c 100644 --- a/packages/api-headless-cms-ddb/__tests__/plugins/dynamoDb/transformValue/datetime.test.ts +++ b/packages/api-headless-cms-ddb/__tests__/plugins/dynamoDb/transformValue/datetime.test.ts @@ -39,6 +39,7 @@ describe("dynamodb transform datetime", () => { const incorrectTimeValues: [any][] = [ [{}], [[]], + [""], [ function () { return 1; @@ -50,14 +51,20 @@ describe("dynamodb transform datetime", () => { test.each(incorrectTimeValues)( "should throw an error when trying to transform time field but value is not a string or a number", value => { + expect.assertions(1); const plugin = createDatetimeTransformValuePlugin(); - expect(() => { - plugin.transform({ + try { + const result = plugin.transform({ field: createField("time"), value }); - }).toThrow("Field value must be a string because field is defined as time."); + expect(result).toEqual("SHOULD NOT HAPPEN"); + } catch (ex) { + expect(ex.message).toEqual( + "Field value must be a string because field is defined as time." + ); + } } ); }); diff --git a/packages/api-headless-cms-ddb/package.json b/packages/api-headless-cms-ddb/package.json index 66636563428..e282997b292 100644 --- a/packages/api-headless-cms-ddb/package.json +++ b/packages/api-headless-cms-ddb/package.json @@ -23,6 +23,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.0", + "@webiny/api": "0.0.0", "@webiny/api-headless-cms": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/db-dynamodb": "0.0.0", diff --git a/packages/api-headless-cms-ddb/src/operations/entry/filtering/plugins/refFilterCreate.ts b/packages/api-headless-cms-ddb/src/operations/entry/filtering/plugins/refFilterCreate.ts index 37d29dc94d1..962cda6d476 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/filtering/plugins/refFilterCreate.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/filtering/plugins/refFilterCreate.ts @@ -5,17 +5,23 @@ import { } from "~/plugins/CmsEntryFieldFilterPlugin"; import { extractWhereParams } from "~/operations/entry/filtering/where"; import { transformValue } from "~/operations/entry/filtering/transform"; +import { GenericRecord } from "@webiny/api/types"; export const createRefFilterCreate = () => { - const plugin = new CmsEntryFieldFilterPlugin({ + const plugin = new CmsEntryFieldFilterPlugin({ fieldType: "ref", create: params => { - const { value, valueFilterPlugins, transformValuePlugins, field } = params; + const { valueFilterPlugins, transformValuePlugins, field } = params; + let value = params.value; + if (!value) { + value = { + entryId: null + }; + } const propertyFilters = Object.keys(value); if (propertyFilters.length === 0) { return null; } - const filters: CmsEntryFieldFilterPluginCreateResponse[] = []; for (const propertyFilter of propertyFilters) { diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts index f2686e2cc23..713ba171e10 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -247,8 +247,10 @@ export const createEntriesStorageOperations = ( /** * We need to: * - create the main entry item - * - update the last entry item to a current one - * - update the published entry item to a current one (if the entry is published) + * - update the latest entry item to the current one + * - if the entry's status was set to "published": + * - update the published entry item to the current one + * - unpublish previously published revision (if any) */ const items = [ entity.putBatch({ @@ -281,6 +283,28 @@ export const createEntriesStorageOperations = ( GSI1_SK: createGSISortKey(storageEntry) }) ); + + // Unpublish previously published revision (if any). + const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId( + { + model, + ids: [entry.id] + } + ); + + if (publishedRevisionStorageEntry) { + items.push( + entity.putBatch({ + ...publishedRevisionStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(publishedRevisionStorageEntry), + TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(publishedRevisionStorageEntry) + }) + ); + } } try { @@ -767,7 +791,7 @@ export const createEntriesStorageOperations = ( // entry-level meta fields to match the previous revision's entry-level meta fields. items.push( entity.putBatch({ - ...initialLatestStorageEntry, + ...latestStorageEntry, PK: partitionKey, SK: createRevisionSortKey(initialLatestStorageEntry), TYPE: createType(), @@ -1165,7 +1189,7 @@ export const createEntriesStorageOperations = ( * Although we do not need a cursor here, we will use it as such to keep it standardized. * Number is simply encoded. */ - const cursor = totalCount > start + limit ? encodeCursor(`${start + limit}`) : null; + const cursor = encodeCursor(`${start + limit}`); return { hasMoreItems, totalCount, @@ -1198,19 +1222,22 @@ export const createEntriesStorageOperations = ( * We need the latest and published entries to see if something needs to be updated alongside the publishing one. */ const initialLatestStorageEntry = await getLatestRevisionByEntryId(model, entry); + if (!initialLatestStorageEntry) { + throw new WebinyError( + `Could not publish entry. Could not load latest ("L") record.`, + "PUBLISH_ERROR", + { entry } + ); + } + const initialPublishedStorageEntry = await getPublishedRevisionByEntryId(model, entry); const storageEntry = convertToStorageEntry({ model, storageEntry: initialStorageEntry }); - /** - * We need to update: - * - current entry revision sort key - * - published sort key - * - the latest sort key - if entry updated is actually latest - * - previous published entry to unpublished status - if any previously published entry - */ + + // 1. Update REV# and P records with new data. const items = [ entity.putBatch({ ...storageEntry, @@ -1230,78 +1257,115 @@ export const createEntriesStorageOperations = ( }) ]; - if (initialLatestStorageEntry) { - const publishingLatestRevision = entry.id === initialLatestStorageEntry.id; + // 2. When it comes to the latest record, we need to perform a couple of different + // updates, based on whether the entry being published is the latest revision or not. + const publishedRevisionId = initialPublishedStorageEntry?.id; + const publishingLatestRevision = entry.id === initialLatestStorageEntry.id; - if (publishingLatestRevision) { - // We want to update current latest record because of the status (`status: 'published'`) update. - items.push( - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(entry) - }) - ); - } else { - const latestStorageEntry = convertToStorageEntry({ - storageEntry: initialLatestStorageEntry, + if (publishingLatestRevision) { + // 2.1 If we're publishing the latest revision, we first need to update the L record. + items.push( + entity.putBatch({ + ...storageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(entry) + }) + ); + + // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished. + if (publishedRevisionId && publishedRevisionId !== entry.id) { + const publishedStorageEntry = convertToStorageEntry({ + storageEntry: initialPublishedStorageEntry, model }); - // If the published revision is not the latest one, we still need to - // update the latest record with the new values of entry-level meta fields. - const updatedEntryLevelMetaFields = pickEntryMetaFields( - entry, - isEntryLevelEntryMetaField - ); - - // 1. Update actual revision record. items.push( entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, + ...publishedStorageEntry, PK: partitionKey, - SK: createRevisionSortKey(latestStorageEntry), + SK: createRevisionSortKey(publishedStorageEntry), TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); - - // 2. Update latest record. - items.push( - entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(latestStorageEntry) + GSI1_SK: createGSISortKey(publishedStorageEntry) }) ); } - } + } else { + // 2.3 If the published revision is not the latest one, the situation is a bit + // more complex. We first need to update the L and REV# records with the new + // values of *only entry-level* meta fields. + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); - if (initialPublishedStorageEntry && initialPublishedStorageEntry.id !== entry.id) { - const publishedStorageEntry = convertToStorageEntry({ - storageEntry: initialPublishedStorageEntry, + const latestStorageEntry = convertToStorageEntry({ + storageEntry: initialLatestStorageEntry, model }); + + // 2.3.1 Update L record. Apart from updating the entry-level meta fields, we also need + // to change the status from "published" to "unpublished" (if the status is set to "published"). + let latestRevisionStatus = latestStorageEntry.status; + if (latestRevisionStatus === CONTENT_ENTRY_STATUS.PUBLISHED) { + latestRevisionStatus = CONTENT_ENTRY_STATUS.UNPUBLISHED; + } + + const latestStorageEntryFields = { + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + status: latestRevisionStatus + }; + items.push( entity.putBatch({ - ...publishedStorageEntry, + ...latestStorageEntryFields, PK: partitionKey, - SK: createRevisionSortKey(publishedStorageEntry), + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }) + ); + + // 2.3.2 Update REV# record. + items.push( + entity.putBatch({ + ...latestStorageEntryFields, + PK: partitionKey, + SK: createRevisionSortKey(latestStorageEntry), TYPE: createType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(publishedStorageEntry) + GSI1_SK: createGSISortKey(latestStorageEntry) }) ); + + // 2.3.3 Finally, if we got a published entry, but it wasn't the latest one, we need to take + // an extra step and mark it as unpublished. + const publishedRevisionDifferentFromLatest = + publishedRevisionId && publishedRevisionId !== latestStorageEntry.id; + if (publishedRevisionDifferentFromLatest) { + const publishedStorageEntry = convertToStorageEntry({ + storageEntry: initialPublishedStorageEntry, + model + }); + + items.push( + entity.putBatch({ + ...publishedStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(publishedStorageEntry), + TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(publishedStorageEntry) + }) + ); + } } try { diff --git a/packages/api-headless-cms-ddb/src/plugins/CmsEntryFieldFilterPlugin.ts b/packages/api-headless-cms-ddb/src/plugins/CmsEntryFieldFilterPlugin.ts index 81fa14a9c77..1ba4db03aef 100644 --- a/packages/api-headless-cms-ddb/src/plugins/CmsEntryFieldFilterPlugin.ts +++ b/packages/api-headless-cms-ddb/src/plugins/CmsEntryFieldFilterPlugin.ts @@ -8,16 +8,16 @@ import { CmsFieldFilterValueTransformPlugin } from "~/types"; * Internally we have default one + the one for the reference field - because it is actually an object when filtering. */ -interface CmsEntryFieldFilterPluginParams { +interface CmsEntryFieldFilterPluginParams { fieldType: string; create: ( - params: CmsEntryFieldFilterPluginCreateParams + params: CmsEntryFieldFilterPluginCreateParams ) => null | CmsEntryFieldFilterPluginCreateResponse | CmsEntryFieldFilterPluginCreateResponse[]; } -interface CmsEntryFieldFilterPluginCreateParams { +interface CmsEntryFieldFilterPluginCreateParams { key: string; - value: any; + value: T; field: Field; fields: Record; operation: string; @@ -39,21 +39,21 @@ export interface CmsEntryFieldFilterPluginCreateResponse { transformValue: (value: I) => O; } -export class CmsEntryFieldFilterPlugin extends Plugin { +export class CmsEntryFieldFilterPlugin extends Plugin { public static override readonly type: string = "cms.dynamodb.entry.field.filter"; public static readonly ALL: string = "*"; - private readonly config: CmsEntryFieldFilterPluginParams; + private readonly config: CmsEntryFieldFilterPluginParams; public readonly fieldType: string; - public constructor(config: CmsEntryFieldFilterPluginParams) { + public constructor(config: CmsEntryFieldFilterPluginParams) { super(); this.config = config; this.fieldType = this.config.fieldType; } - public create(params: CmsEntryFieldFilterPluginCreateParams) { + public create(params: CmsEntryFieldFilterPluginCreateParams) { return this.config.create(params); } } diff --git a/packages/api-headless-cms-ddb/tsconfig.build.json b/packages/api-headless-cms-ddb/tsconfig.build.json index bf17bbe28dd..b22550f95d2 100644 --- a/packages/api-headless-cms-ddb/tsconfig.build.json +++ b/packages/api-headless-cms-ddb/tsconfig.build.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.build.json", "include": ["src"], "references": [ + { "path": "../api/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, diff --git a/packages/api-headless-cms-ddb/tsconfig.json b/packages/api-headless-cms-ddb/tsconfig.json index 9e355ca7a3f..ab91f5e37b0 100644 --- a/packages/api-headless-cms-ddb/tsconfig.json +++ b/packages/api-headless-cms-ddb/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "include": ["src", "__tests__"], "references": [ + { "path": "../api" }, { "path": "../api-headless-cms" }, { "path": "../aws-sdk" }, { "path": "../db-dynamodb" }, @@ -17,6 +18,8 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeletion.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeletion.test.ts new file mode 100644 index 00000000000..a9e9c988e57 --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeletion.test.ts @@ -0,0 +1,73 @@ +import { useTestModelHandler } from "~tests/testHelpers/useTestModelHandler"; +import { identityA } from "./security/utils"; + +describe("Content entries - Entry Deletion", () => { + const { manage, read } = useTestModelHandler({ + identity: identityA + }); + + beforeEach(async () => { + await manage.setup(); + }); + + test("MANAGE/READ GraphQL APIs should reflect revision deletions correctly", async () => { + const { data: revision1 } = await manage.createTestEntry({ data: { title: "Revision 1" } }); + + const { data: revision2 } = await manage.createTestEntryFrom({ + revision: revision1.id, + data: { title: "Revision 2" } + }); + + const { data: revision3 } = await manage.createTestEntryFrom({ + revision: revision2.id, + data: { title: "Revision 3" } + }); + + await manage.publishTestEntry({ + revision: revision3.id + }); + + let { data: manageEntriesList } = await manage.listTestEntries(); + let { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { id: revision3.id, title: "Revision 3", meta: { status: "published" } } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision3.id, title: "Revision 3" }]); + + await manage.deleteTestEntry({ revision: revision3.id }); + + ({ data: manageEntriesList } = await manage.listTestEntries()); + ({ data: readEntriesList } = await read.listTestEntries()); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { id: revision2.id, title: "Revision 2", meta: { status: "draft" } } + ]); + + expect(readEntriesList).toHaveLength(0); + + await manage.deleteTestEntry({ revision: revision2.id }); + + ({ data: manageEntriesList } = await manage.listTestEntries()); + ({ data: readEntriesList } = await read.listTestEntries()); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { id: revision1.id, title: "Revision 1", meta: { status: "draft" } } + ]); + + expect(readEntriesList).toHaveLength(0); + + await manage.deleteTestEntry({ revision: revision1.id }); + + ({ data: manageEntriesList } = await manage.listTestEntries()); + ({ data: readEntriesList } = await read.listTestEntries()); + + expect(manageEntriesList).toHaveLength(0); + expect(readEntriesList).toHaveLength(0); + }); +}); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts index fa8a2bce373..c48126164a6 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts @@ -184,6 +184,8 @@ describe("Content entries - Entry Meta Fields", () => { test("deleting latest revision should cause the entry-level meta field values to be propagated to the new latest revision", async () => { let { data: revision1 } = await manageApiIdentityA.createTestEntry(); + const { title, slug } = revision1; + let { data: revision2 } = await manageApiIdentityA.createTestEntryFrom({ revision: revision1.id }); @@ -215,6 +217,9 @@ describe("Content entries - Entry Meta Fields", () => { expect(revision2.savedOn).toBe(entriesList[0].savedOn); expect(revision2.savedBy).toEqual(entriesList[0].savedBy); + expect(revision2.title).toBe(title); + expect(revision2.slug).toBe(slug); + // Delete revision 2 and ensure that revision 1's entry-level meta fields are propagated. await manageApiIdentityB.deleteTestEntry({ revision: revision2.id @@ -237,5 +242,8 @@ describe("Content entries - Entry Meta Fields", () => { expect(revision1.modifiedBy).toEqual(entriesList[0].modifiedBy); expect(revision1.savedOn).toBe(entriesList[0].savedOn); expect(revision1.savedBy).toEqual(entriesList[0].savedBy); + + expect(revision1.title).toBe(title); + expect(revision1.slug).toBe(slug); }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts index ee008997469..11426f24fd1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts @@ -163,5 +163,17 @@ describe("Content entries - Entry Meta Fields Overrides", () => { const { data: listEntriesRead } = await readIdentityA.listTestEntries(); expect(listEntriesRead[0].id).toEndWith("#0003"); + + // Extra check - ensure the previous revision is no longer published. + const { data: firstPublishedRevision } = await manageIdentityA.getTestEntry({ + revision: `${rev.entryId}#0001` + }); + + const { data: secondPublishedRevision } = await manageIdentityA.getTestEntry({ + revision: `${rev.entryId}#0002` + }); + + expect(firstPublishedRevision.meta.status).toBe("unpublished"); + expect(secondPublishedRevision.meta.status).toBe("unpublished"); }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.publishOldPublishedRevisions.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.publishOldPublishedRevisions.test.ts new file mode 100644 index 00000000000..e58d668ccd0 --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.publishOldPublishedRevisions.test.ts @@ -0,0 +1,271 @@ +import { useTestModelHandler } from "~tests/testHelpers/useTestModelHandler"; +import { identityA } from "./security/utils"; + +describe("Content entries - Entry Publishing", () => { + const { manage, read } = useTestModelHandler({ + identity: identityA + }); + + beforeEach(async () => { + await manage.setup(); + }); + + test("should be able to publish a previously published revision (entry already has the latest revision published)", async () => { + const { data: revision1 } = await manage.createTestEntry({ data: { title: "Revision 1" } }); + + await manage.publishTestEntry({ + revision: revision1.id + }); + + const { data: revision2 } = await manage.createTestEntryFrom({ + revision: revision1.id, + data: { title: "Revision 2" } + }); + + await manage.publishTestEntry({ + revision: revision2.id + }); + + // Let's publish revision 1 again. + await manage.publishTestEntry({ + revision: revision1.id + }); + + const { data: manageEntriesList } = await manage.listTestEntries(); + const { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { id: revision2.id, title: "Revision 2", meta: { status: "unpublished" } } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision1.id, title: "Revision 1" }]); + }); + + test("should be able to publish a previously published revision (entry already has a non-latest revision published)", async () => { + const { data: revision1 } = await manage.createTestEntry({ + data: { title: "Revision 1" } + }); + + const { data: revision2 } = await manage.createTestEntryFrom({ + revision: revision1.id, + data: { title: "Revision 2" } + }); + + // Let's publish revision 2. + await manage.publishTestEntry({ + revision: revision2.id + }); + + const { data: revision3 } = await manage.createTestEntryFrom({ + revision: revision2.id, + data: { title: "Revision 3" } + }); + + // Let's publish revision 3. + await manage.publishTestEntry({ + revision: revision3.id + }); + + const { data: revision4 } = await manage.createTestEntryFrom({ + revision: revision3.id, + data: { title: "Revision 4" } + }); + + { + const { data: manageEntriesList } = await manage.listTestEntries(); + const { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { + id: revision4.id, + title: "Revision 4", + meta: { + status: "draft", + revisions: [ + { + title: "Revision 4", + slug: revision1.slug, + meta: { status: "draft", version: 4 } + }, + { + title: "Revision 3", + slug: revision1.slug, + meta: { status: "published", version: 3 } + }, + { + title: "Revision 2", + slug: revision1.slug, + meta: { status: "unpublished", version: 2 } + }, + { + title: "Revision 1", + slug: revision1.slug, + meta: { status: "draft", version: 1 } + } + ] + } + } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision3.id, title: "Revision 3" }]); + } + + // Let's publish older revision 2 . + await manage.publishTestEntry({ + revision: revision2.id + }); + + { + const { data: manageEntriesList } = await manage.listTestEntries(); + const { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { + id: revision4.id, + title: "Revision 4", + meta: { + status: "draft", + revisions: [ + { + title: "Revision 4", + slug: revision1.slug, + meta: { status: "draft", version: 4 } + }, + { + title: "Revision 3", + slug: revision1.slug, + meta: { status: "unpublished", version: 3 } + }, + { + title: "Revision 2", + slug: revision1.slug, + meta: { status: "published", version: 2 } + }, + { + title: "Revision 1", + slug: revision1.slug, + meta: { status: "draft", version: 1 } + } + ] + } + } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision2.id, title: "Revision 2" }]); + } + }); + + test("should be able to publish a previously published revision (entry already has a non-latest revision published, using `createFrom` mutations to publish in this test)", async () => { + const { data: revision1 } = await manage.createTestEntry({ + data: { title: "Revision 1" } + }); + + const { data: revision2 } = await manage.createTestEntryFrom({ + revision: revision1.id, + data: { title: "Revision 2", status: "published" } + }); + + const { data: revision3 } = await manage.createTestEntryFrom({ + revision: revision2.id, + data: { title: "Revision 3", status: "published" } + }); + + const { data: revision4 } = await manage.createTestEntryFrom({ + revision: revision3.id, + data: { title: "Revision 4" } + }); + + { + const { data: manageEntriesList } = await manage.listTestEntries(); + const { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { + id: revision4.id, + title: "Revision 4", + meta: { + status: "draft", + revisions: [ + { + title: "Revision 4", + slug: revision1.slug, + meta: { status: "draft", version: 4 } + }, + { + title: "Revision 3", + slug: revision1.slug, + meta: { status: "published", version: 3 } + }, + { + title: "Revision 2", + slug: revision1.slug, + meta: { status: "unpublished", version: 2 } + }, + { + title: "Revision 1", + slug: revision1.slug, + meta: { status: "draft", version: 1 } + } + ] + } + } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision3.id, title: "Revision 3" }]); + } + + // Let's publish older revision 2. + await manage.publishTestEntry({ + revision: revision2.id + }); + + { + const { data: manageEntriesList } = await manage.listTestEntries(); + const { data: readEntriesList } = await read.listTestEntries(); + + expect(manageEntriesList).toHaveLength(1); + expect(manageEntriesList).toMatchObject([ + { + id: revision4.id, + title: "Revision 4", + meta: { + status: "draft", + revisions: [ + { + title: "Revision 4", + slug: revision1.slug, + meta: { status: "draft", version: 4 } + }, + { + title: "Revision 3", + slug: revision1.slug, + meta: { status: "unpublished", version: 3 } + }, + { + title: "Revision 2", + slug: revision1.slug, + meta: { status: "published", version: 2 } + }, + { + title: "Revision 1", + slug: revision1.slug, + meta: { status: "draft", version: 1 } + } + ] + } + } + ]); + + expect(readEntriesList).toHaveLength(1); + expect(readEntriesList).toMatchObject([{ id: revision2.id, title: "Revision 2" }]); + } + }); +}); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index 7a91f9d558b..24005cc9e5f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -2,6 +2,7 @@ import models from "./mocks/contentModels"; import { CmsEntry, CmsGroup, CmsModel } from "~/types"; import { useCategoryManageHandler } from "../testHelpers/useCategoryManageHandler"; import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; +import { createMockCmsEntry } from "~tests/helpers/createMockCmsEntry"; const manageOpts = { path: "manage/en-US" @@ -90,7 +91,7 @@ describe("Content Entry Meta Field", () => { it("storage operations - should have meta field data in the retrieved record", async () => { const { model } = await setup(); const entryId = generateAlphaNumericLowerCaseId(8); - const entry: CmsEntry = { + const entry = createMockCmsEntry({ id: `${entryId}#0001`, entryId, version: 1, @@ -117,7 +118,7 @@ describe("Content Entry Meta Field", () => { status: "draft", webinyVersion: "5.27.0", meta: createMetaData() - }; + }); const createdRecord = await storageOperations.entries.create(model, { entry, @@ -177,7 +178,7 @@ describe("Content Entry Meta Field", () => { meta: createMetaData() } ], - cursor: null, + cursor: expect.any(String), totalCount: 1 }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts index 229b660d139..1643a2658f3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts @@ -1,6 +1,12 @@ import { createContentModelGroup } from "./contentModelGroup"; import { CmsModel } from "~/types"; -import { CmsModelInput, createCmsGroupPlugin, createCmsModelPlugin } from "~/plugins"; +import { + CmsGroupPlugin, + CmsModelInput, + CmsModelPlugin, + createCmsGroupPlugin, + createCmsModelPlugin +} from "~/plugins"; const { version: webinyVersion } = require("@webiny/cli/package.json"); @@ -247,6 +253,109 @@ const models: CmsModel[] = [ tenant: "root", webinyVersion }, + // category + { + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + locale: "en-US", + titleFieldId: "title", + lockedFields: [], + name: "Category Singleton", + description: "Product category Singleton", + modelId: "categorySingleton", + singularApiName: "CategoryApiNameWhichIsABitDifferentThanModelIdSingleton", + pluralApiName: "CategoriesApiModelSingleton", + group: { + id: contentModelGroup.id, + name: contentModelGroup.name + }, + layout: [[ids.field11], [ids.field12]], + fields: [ + { + id: ids.field11, + multipleValues: false, + helpText: "", + label: "Title", + type: "text", + storageId: "text@titleStorageId", + fieldId: "title", + validation: [ + { + name: "required", + message: "This field is required" + }, + { + name: "minLength", + message: "Enter at least 3 characters", + settings: { + min: 3.0 + } + } + ], + listValidation: [], + placeholderText: "placeholder text", + predefinedValues: { + enabled: false, + values: [] + }, + renderer: { + name: "renderer" + } + }, + { + id: ids.field12, + multipleValues: false, + helpText: "", + label: "Slug", + type: "text", + storageId: "text@slugStorageId", + fieldId: "slug", + validation: [ + { + name: "required", + message: "This field is required" + } + ], + listValidation: [], + placeholderText: "placeholder text", + predefinedValues: { + enabled: false, + values: [] + }, + renderer: { + name: "renderer" + } + }, + { + id: ids.field34, + multipleValues: false, + helpText: "", + label: "Category", + type: "ref", + storageId: "ref@categoryRef", + fieldId: "categoryRef", + validation: [], + listValidation: [], + placeholderText: "placeholder text", + settings: { + models: [ + { + modelId: "categorySingleton" + } + ] + }, + predefinedValues: { + enabled: false, + values: [] + }, + renderer: { + name: "renderer" + } + } + ], + tenant: "root", + webinyVersion + }, // product { createdOn: new Date().toISOString(), @@ -1855,3 +1964,19 @@ export const createModelPlugins = (targets: string[]) => { }) ]; }; + +export const createPluginFromCmsModel = ( + model: Omit +): (CmsModelPlugin | CmsGroupPlugin)[] => { + return [ + createCmsGroupPlugin(contentModelGroup), + createCmsModelPlugin({ + ...model, + group: { + id: contentModelGroup.id, + name: contentModelGroup.name + }, + noValidate: true + }) + ]; +}; diff --git a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts index 0d15e59aa27..3a283574655 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts @@ -123,7 +123,7 @@ describe("refField", () => { return publishAuthorResponse.data.publishAuthor.data; }; - test("should create review connected to a product", async () => { + it("should create review connected to a product", async () => { await setupContentModels(mainHandler); const category = await createCategory(); @@ -441,4 +441,131 @@ describe("refField", () => { } }); }); + + it("should create a product which is not connected to category and list and filter by the category value", async () => { + await setupContentModels(mainHandler); + const { createProduct, listProducts } = useProductManageHandler({ + ...manageOpts + }); + + const [listEmptyResult] = await listProducts({ + where: { + category: null + } + }); + expect(listEmptyResult).toMatchObject({ + data: { + listProducts: { + data: [], + error: null, + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 0 + } + } + } + }); + + const data = { + title: "Potato", + price: 100, + availableOn: "2020-12-25", + color: "white", + availableSizes: ["s", "m"], + image: "file.jpg", + category: null + }; + const [createResponse] = await createProduct({ + data + }); + + expect(createResponse).toMatchObject({ + data: { + createProduct: { + data: { + id: expect.any(String), + ...data, + category: null + }, + error: null + } + } + }); + + const [listResult] = await listProducts(); + + expect(listResult).toMatchObject({ + data: { + listProducts: { + data: [ + { + id: expect.any(String), + entryId: expect.any(String), + ...data, + category: null + } + ], + error: null, + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + } + } + } + }); + + const [whereNullResult] = await listProducts({ + where: { + category: null + } + }); + expect(whereNullResult).toMatchObject({ + data: { + listProducts: { + data: [ + { + id: expect.any(String), + entryId: expect.any(String), + ...data, + category: null + } + ], + error: null, + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + } + } + } + }); + + const [whereUndefinedResult] = await listProducts({ + where: { + category: undefined + } + }); + expect(whereUndefinedResult).toMatchObject({ + data: { + listProducts: { + data: [ + { + id: expect.any(String), + entryId: expect.any(String), + ...data, + category: null + } + ], + error: null, + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + } + } + } + }); + }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/singletonContentEntry.test.ts b/packages/api-headless-cms/__tests__/contentAPI/singletonContentEntry.test.ts new file mode 100644 index 00000000000..6659bd37add --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentAPI/singletonContentEntry.test.ts @@ -0,0 +1,181 @@ +import { useSingletonCategoryHandler } from "~tests/testHelpers/useSingletonCategoryHandler"; +import { createPluginFromCmsModel, getCmsModel } from "~tests/contentAPI/mocks/contentModels"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants"; +// import { useHandler } from "~tests/testHelpers/useHandler"; + +describe("singleton model content entries", () => { + const plugins = createPluginFromCmsModel({ + ...getCmsModel("categorySingleton"), + tags: [CMS_MODEL_SINGLETON_TAG] + }); + + // const { handler: contextHandler } = useHandler({ + // plugins + // }); + + const manager = useSingletonCategoryHandler({ + path: `manage/en-US`, + plugins + }); + const reader = useSingletonCategoryHandler({ + path: `read/en-US`, + plugins + }); + + it("should fetch the singleton entry", async () => { + const [managerResponse] = await manager.getCategory(); + + expect(managerResponse).toEqual({ + data: { + getCategory: { + data: { + createdBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + createdOn: expect.toBeDateString(), + deletedBy: null, + deletedOn: null, + entryId: expect.any(String), + firstPublishedOn: null, + id: expect.any(String), + lastPublishedOn: null, + modifiedBy: null, + modifiedOn: null, + restoredBy: null, + restoredOn: null, + savedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + savedOn: expect.toBeDateString(), + slug: null, + title: null + }, + error: null + } + } + }); + + const item = managerResponse.data.getCategory.data; + + const [readerResponse] = await reader.getCategory(); + expect(readerResponse).toEqual({ + data: { + getCategory: { + data: item, + error: null + } + } + }); + }); + + it("should update the singleton entry", async () => { + const data = { + title: "New title", + slug: "new-title" + }; + const [managerResponse] = await manager.updateCategory({ + data + }); + + expect(managerResponse).toMatchObject({ + data: { + updateCategory: { + data, + error: null + } + } + }); + + const [readerResponse] = await reader.getCategory(); + expect(readerResponse).toMatchObject({ + data: { + getCategory: { + data, + error: null + } + } + }); + }); + + it("should update the singleton entry multiple times in a row", async () => { + const [rootResponse] = await reader.getCategory(); + expect(rootResponse).toMatchObject({ + data: { + getCategory: { + data: { + id: expect.any(String) + }, + error: null + } + } + }); + + const id = rootResponse.data.getCategory.data.id; + + const data = { + title: "New title", + slug: "new-title" + }; + const [response1] = await manager.updateCategory({ + data + }); + expect(response1).toMatchObject({ + data: { + updateCategory: { + data: { + ...data, + id + }, + error: null + } + } + }); + + const [response2] = await manager.updateCategory({ + data + }); + expect(response2).toMatchObject({ + data: { + updateCategory: { + data: { + ...data, + id + }, + error: null + } + } + }); + + const [response3] = await manager.updateCategory({ + data + }); + expect(response3).toMatchObject({ + data: { + updateCategory: { + data: { + ...data, + id + }, + error: null + } + } + }); + + const [readerResponse] = await reader.getCategory(); + expect(readerResponse).toMatchObject({ + data: { + getCategory: { + data: { + ...data, + id + }, + error: null + } + } + }); + }); +}); diff --git a/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts b/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts new file mode 100644 index 00000000000..a4288596865 --- /dev/null +++ b/packages/api-headless-cms/__tests__/helpers/createMockCmsEntry.ts @@ -0,0 +1,7 @@ +import { CmsEntry } from "~/types"; + +export const createMockCmsEntry = (input: Partial): T => { + return { + ...input + } as T; +}; diff --git a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts index bfab26d276f..6920c5331c5 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts @@ -107,7 +107,7 @@ describe("Entries storage operations", () => { expect(result.items).toHaveLength(amount); expect(result).toMatchObject({ - cursor: null, + cursor: expect.any(String), hasMoreItems: false, totalCount: amount }); diff --git a/packages/api-headless-cms/__tests__/testHelpers/useSingletonCategoryHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useSingletonCategoryHandler.ts new file mode 100644 index 00000000000..af5b5dc5cb2 --- /dev/null +++ b/packages/api-headless-cms/__tests__/testHelpers/useSingletonCategoryHandler.ts @@ -0,0 +1,92 @@ +import { GraphQLHandlerParams, useGraphQLHandler } from "./useGraphQLHandler"; +import { CmsModel } from "~/types"; +import { getCmsModel } from "~tests/contentAPI/mocks/contentModels"; +import { GenericRecord } from "@webiny/api/types"; + +const identityFields = /* GraphQL */ ` + { + id + displayName + type + } +`; + +const categoryFields = ` + id + entryId + createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn + deletedOn + restoredOn + createdBy ${identityFields} + modifiedBy ${identityFields} + savedBy ${identityFields} + deletedBy ${identityFields} + restoredBy ${identityFields} + # user fields + title + slug +`; + +const errorFields = ` + error { + code + message + data + } +`; + +const getCategoryQuery = (model: CmsModel) => { + return /* GraphQL */ ` + query GetCategory { + getCategory: get${model.singularApiName} { + data { + ${categoryFields} + } + ${errorFields} + } + } + `; +}; + +const updateCategoryMutation = (model: CmsModel) => { + return /* GraphQL */ ` + mutation UpdateCategory($data: ${model.singularApiName}Input!) { + updateCategory: update${model.singularApiName}(data: $data) { + data { + ${categoryFields} + } + ${errorFields} + } + } + `; +}; + +export const useSingletonCategoryHandler = (params: GraphQLHandlerParams) => { + const model = getCmsModel("categorySingleton"); + const contentHandler = useGraphQLHandler(params); + + return { + ...contentHandler, + async getCategory(headers: GenericRecord = {}) { + return await contentHandler.invoke({ + body: { + query: getCategoryQuery(model) + }, + headers + }); + }, + async updateCategory(variables: Record, headers: Record = {}) { + return await contentHandler.invoke({ + body: { + query: updateCategoryMutation(model), + variables + }, + headers + }); + } + }; +}; diff --git a/packages/api-headless-cms/src/constants.ts b/packages/api-headless-cms/src/constants.ts index 84d9886a5bc..ef271526a18 100644 --- a/packages/api-headless-cms/src/constants.ts +++ b/packages/api-headless-cms/src/constants.ts @@ -2,6 +2,8 @@ import { CmsIdentity } from "~/types"; export const ROOT_FOLDER = "root"; +export const CMS_MODEL_SINGLETON_TAG = "singleton"; + // Content entries - xOn and xBy meta fields. export const ENTRY_META_FIELDS = [ // Entry-level meta fields. diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index d6727699d36..d5739547c2e 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -46,9 +46,10 @@ import { listModelsFromDatabase } from "~/crud/contentModel/listModelsFromDataba import { filterAsync } from "~/utils/filterAsync"; import { AccessControl } from "./AccessControl/AccessControl"; import { - CmsModelToAstConverter, - CmsModelFieldToAstConverterFromPlugins + CmsModelFieldToAstConverterFromPlugins, + CmsModelToAstConverter } from "~/utils/contentModelAst"; +import { SingletonModelManager } from "~/modelManager"; export interface CreateModelsCrudParams { getTenant: () => Tenant; @@ -220,6 +221,16 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex return await updateManager(context, model); }; + const getSingletonEntryManager = async ( + input: CmsModel | string + ) => { + const model = typeof input === "string" ? await getModel(input) : input; + + const manager = await getEntryManager(model); + + return SingletonModelManager.create(manager); + }; + /** * Create */ @@ -694,6 +705,7 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex ); }, getEntryManager, - getEntryManagers: () => managers + getEntryManagers: () => managers, + getSingletonEntryManager }; }; diff --git a/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts b/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts index 5a1574f8d55..fa44538755e 100644 --- a/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts +++ b/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts @@ -2,6 +2,7 @@ import { Topic } from "@webiny/pubsub/types"; import { CmsContext, OnModelBeforeDeleteTopicParams } from "~/types"; import WebinyError from "@webiny/error"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants"; interface AssignBeforeModelDeleteParams { onModelBeforeDelete: Topic; @@ -27,6 +28,42 @@ export const assignModelBeforeDelete = (params: AssignBeforeModelDeleteParams) = ); } + const tags = Array.isArray(model.tags) ? model.tags : []; + /** + * If the model is a singleton, we need to delete all entries. + * There will be either 0 or 1 entries in latest or deleted, but let's put high limit, just in case... + */ + if (tags.includes(CMS_MODEL_SINGLETON_TAG)) { + const [latestEntries] = await context.cms.listLatestEntries(model, { + limit: 10000 + }); + + if (latestEntries.length > 0) { + for (const item of latestEntries) { + await context.cms.deleteEntry(model, item.id, { + permanently: true + }); + } + return; + } + + const [deletedEntries] = await context.cms.listDeletedEntries(model, { + limit: 10000 + }); + + if (deletedEntries.length === 0) { + return; + } + + for (const item of deletedEntries) { + await context.cms.deleteEntry(model, item.id, { + permanently: true + }); + } + + return; + } + try { const [latestEntries] = await context.cms.listLatestEntries(model, { limit: 1 }); diff --git a/packages/api-headless-cms/src/export/crud/sanitize.ts b/packages/api-headless-cms/src/export/crud/sanitize.ts index d7f1dd09f92..37e82332997 100644 --- a/packages/api-headless-cms/src/export/crud/sanitize.ts +++ b/packages/api-headless-cms/src/export/crud/sanitize.ts @@ -25,6 +25,6 @@ export const sanitizeModel = (group: Pick, model: CmsModel): San titleFieldId: model.titleFieldId, descriptionFieldId: model.descriptionFieldId, imageFieldId: model.imageFieldId, - tags: model.tags + tags: model.tags || [] }; }; diff --git a/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts b/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts index 402c6e13691..e08d847b527 100644 --- a/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts +++ b/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts @@ -30,6 +30,7 @@ export const createManageSDL: CreateManageSDL = ({ fields: model.fields, fieldTypePlugins }); + const listFilterFieldsRender = renderListFilterFields({ model, fields: model.fields, diff --git a/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts b/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts index 70a0e9ee467..5299d825756 100644 --- a/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts +++ b/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts @@ -32,6 +32,7 @@ export const createReadSDL: CreateReadSDL = ({ type, fieldTypePlugins }); + const listFilterFieldsRender = renderListFilterFields({ model, fields: model.fields, diff --git a/packages/api-headless-cms/src/graphql/schema/createSingularResolvers.ts b/packages/api-headless-cms/src/graphql/schema/createSingularResolvers.ts new file mode 100644 index 00000000000..c1327e8d742 --- /dev/null +++ b/packages/api-headless-cms/src/graphql/schema/createSingularResolvers.ts @@ -0,0 +1,64 @@ +import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel } from "~/types"; +import { resolveGet } from "./resolvers/singular/resolveGet"; +import { resolveUpdate } from "./resolvers/singular/resolveUpdate"; +import { normalizeGraphQlInput } from "./resolvers/manage/normalizeGraphQlInput"; +import { createFieldResolversFactory } from "./createFieldResolvers"; + +interface CreateSingularResolversParams { + models: CmsModel[]; + model: CmsModel; + context: CmsContext; + fieldTypePlugins: CmsFieldTypePlugins; + type: ApiEndpoint; +} + +interface CreateSingularResolvers { + // TODO @ts-refactor determine correct type. + (params: CreateSingularResolversParams): any; +} + +export const createSingularResolvers: CreateSingularResolvers = ({ + models, + model, + fieldTypePlugins, + type +}) => { + if (model.fields.length === 0) { + return { + Query: {}, + Mutation: {} + }; + } + + const createFieldResolvers = createFieldResolversFactory({ + endpointType: type, + models, + model, + fieldTypePlugins + }); + + const fieldResolvers = createFieldResolvers({ + graphQLType: model.singularApiName, + fields: model.fields, + isRoot: true + }); + + const resolverFactoryParams = { model, fieldTypePlugins }; + + const result = { + Query: { + [`get${model.singularApiName}`]: resolveGet(resolverFactoryParams) + }, + ...fieldResolvers + }; + if (type !== "manage") { + return result; + } + return { + ...result, + Mutation: { + [`update${model.singularApiName}`]: + normalizeGraphQlInput(resolveUpdate)(resolverFactoryParams) + } + }; +}; diff --git a/packages/api-headless-cms/src/graphql/schema/createSingularSDL.ts b/packages/api-headless-cms/src/graphql/schema/createSingularSDL.ts new file mode 100644 index 00000000000..55f1e183c86 --- /dev/null +++ b/packages/api-headless-cms/src/graphql/schema/createSingularSDL.ts @@ -0,0 +1,100 @@ +import { ApiEndpoint, CmsFieldTypePlugins, CmsModel } from "~/types"; +import { renderInputFields } from "~/utils/renderInputFields"; +import { renderFields } from "~/utils/renderFields"; +import { ENTRY_META_FIELDS, isDateTimeEntryMetaField } from "~/constants"; + +interface CreateSingularSDLParams { + models: CmsModel[]; + model: CmsModel; + fieldTypePlugins: CmsFieldTypePlugins; + type: ApiEndpoint; +} + +interface CreateSingularSDL { + (params: CreateSingularSDLParams): string; +} + +export const createSingularSDL: CreateSingularSDL = ({ + models, + model, + fieldTypePlugins, + type +}): string => { + const inputFields = renderInputFields({ + models, + model, + fields: model.fields, + fieldTypePlugins + }); + if (inputFields.length === 0) { + return ""; + } + + const fields = renderFields({ + models, + model, + fields: model.fields, + type, + fieldTypePlugins + }); + + const { singularApiName: singularName } = model; + + const inputGqlFields = inputFields.map(f => f.fields).join("\n"); + + const onByMetaInputGqlFields = ENTRY_META_FIELDS.map(field => { + const fieldType = isDateTimeEntryMetaField(field) ? "DateTime" : "CmsIdentityInput"; + + return `${field}: ${fieldType}`; + }).join("\n"); + + const onByMetaGqlFields = ENTRY_META_FIELDS.map(field => { + const fieldType = isDateTimeEntryMetaField(field) ? "DateTime" : "CmsIdentity"; + + return `${field}: ${fieldType}`; + }).join("\n"); + + // Had to remove /* GraphQL */ because prettier would not format the code correctly. + const read = ` + """${model.description || singularName}""" + type ${singularName} { + id: ID! + entryId: String! + + ${onByMetaGqlFields} + + ownedBy: CmsIdentity @deprecated(reason: "Field was removed with the 5.39.0 release. Use 'createdBy' field.") + + ${fields.map(f => f.fields).join("\n")} + } + + ${fields.map(f => f.typeDefs).join("\n")} + + type ${singularName}Response { + data: ${singularName} + error: CmsError + } + + extend type Query { + get${singularName}: ${singularName}Response + } + + `; + if (type !== "manage") { + return read; + } + return ` + ${read} + + ${inputFields.map(f => f.typeDefs).join("\n")} + + input ${singularName}Input { + ${onByMetaInputGqlFields} + ${inputGqlFields} + } + + extend type Mutation { + update${singularName}(data: ${singularName}Input!, options: UpdateCmsEntryOptionsInput): ${singularName}Response + } + `; +}; diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts new file mode 100644 index 00000000000..7241d130f39 --- /dev/null +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts @@ -0,0 +1,21 @@ +import { ErrorResponse, Response } from "@webiny/handler-graphql/responses"; +import { CmsEntryResolverFactory as ResolverFactory } from "~/types"; + +interface ResolveGetArgs { + revision: string; +} + +type ResolveGet = ResolverFactory; + +export const resolveGet: ResolveGet = + ({ model }) => + async (_: unknown, __: unknown, context) => { + try { + const manager = await context.cms.getSingletonEntryManager(model); + const entry = await manager.get(); + + return new Response(entry); + } catch (e) { + return new ErrorResponse(e); + } + }; diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts new file mode 100644 index 00000000000..5b8db7e6b35 --- /dev/null +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts @@ -0,0 +1,26 @@ +import { ErrorResponse, Response } from "@webiny/handler-graphql/responses"; +import { + CmsEntryResolverFactory as ResolverFactory, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "~/types"; + +interface ResolveUpdateArgs { + data: UpdateCmsEntryInput; + options?: UpdateCmsEntryOptionsInput; +} + +type ResolveUpdate = ResolverFactory; + +export const resolveUpdate: ResolveUpdate = + ({ model }) => + async (_: unknown, args, context) => { + try { + const manager = await context.cms.getSingletonEntryManager(model.modelId); + const entry = await manager.update(args.data, args.options); + + return new Response(entry); + } catch (e) { + return new ErrorResponse(e); + } + }; diff --git a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts index 68240346409..cac7f5e45c6 100644 --- a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts +++ b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts @@ -10,7 +10,10 @@ import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; -import { createFieldTypePluginRecords } from "~/graphql/schema/createFieldTypePluginRecords"; +import { createFieldTypePluginRecords } from "./createFieldTypePluginRecords"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants"; +import { createSingularSDL } from "./createSingularSDL"; +import { createSingularResolvers } from "./createSingularResolvers"; interface GenerateSchemaPluginsParams { context: CmsContext; @@ -46,6 +49,26 @@ export const generateSchemaPlugins = async ( }); models.forEach(model => { + if (model.tags?.includes(CMS_MODEL_SINGLETON_TAG)) { + const plugin = createCmsGraphQLSchemaPlugin({ + typeDefs: createSingularSDL({ + models, + model, + fieldTypePlugins, + type + }), + resolvers: createSingularResolvers({ + context, + models, + model, + fieldTypePlugins, + type + }) + }); + plugin.name = `headless-cms.graphql.schema.singular.${model.modelId}`; + schemaPlugins.push(plugin); + return; + } switch (type) { case "manage": { diff --git a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts index 1df298e9bf3..8a7ac3aafc5 100644 --- a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts +++ b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts @@ -4,57 +4,63 @@ import { CmsContext, CmsEntryListParams, CreateCmsEntryInput, - UpdateCmsEntryInput + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput, + CreateCmsEntryOptionsInput } from "~/types"; import { parseIdentifier } from "@webiny/utils"; export class DefaultCmsModelManager implements CmsModelManager { private readonly _context: CmsContext; - private readonly _model: CmsModel; + public readonly model: CmsModel; public constructor(context: CmsContext, model: CmsModel) { this._context = context; - this._model = model; + this.model = model; } - public async create(data: CreateCmsEntryInput) { - return this._context.cms.createEntry(this._model, data); + public async create(data: CreateCmsEntryInput, options?: CreateCmsEntryOptionsInput) { + return this._context.cms.createEntry(this.model, data, options); } public async delete(id: string) { const { version } = parseIdentifier(id); if (version) { - return this._context.cms.deleteEntryRevision(this._model, id); + return this._context.cms.deleteEntryRevision(this.model, id); } - return this._context.cms.deleteEntry(this._model, id); + return this._context.cms.deleteEntry(this.model, id); } public async get(id: string) { - return this._context.cms.getEntryById(this._model, id); + return this._context.cms.getEntryById(this.model, id); } public async listPublished(params: CmsEntryListParams) { - return this._context.cms.listPublishedEntries(this._model, params); + return this._context.cms.listPublishedEntries(this.model, params); } public async listLatest(params: CmsEntryListParams) { - return this._context.cms.listLatestEntries(this._model, params); + return this._context.cms.listLatestEntries(this.model, params); } public async listDeleted(params: CmsEntryListParams) { - return this._context.cms.listDeletedEntries(this._model, params); + return this._context.cms.listDeletedEntries(this.model, params); } public async getPublishedByIds(ids: string[]) { - return this._context.cms.getPublishedEntriesByIds(this._model, ids); + return this._context.cms.getPublishedEntriesByIds(this.model, ids); } public async getLatestByIds(ids: string[]) { - return this._context.cms.getLatestEntriesByIds(this._model, ids); + return this._context.cms.getLatestEntriesByIds(this.model, ids); } - public async update(id: string, data: UpdateCmsEntryInput) { - return this._context.cms.updateEntry(this._model, id, data); + public async update( + id: string, + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ) { + return this._context.cms.updateEntry(this.model, id, data, options); } } diff --git a/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts b/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts new file mode 100644 index 00000000000..07204152b65 --- /dev/null +++ b/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts @@ -0,0 +1,66 @@ +import { + CmsEntry, + CmsEntryValues, + CmsModelManager, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "~/types"; +import { WebinyError } from "@webiny/error"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants"; +import { createCacheKey } from "@webiny/utils"; + +export interface ISingletonModelManager { + update(data: UpdateCmsEntryInput, options?: UpdateCmsEntryOptionsInput): Promise>; + get(): Promise>; +} + +export class SingletonModelManager implements ISingletonModelManager { + private readonly manager: CmsModelManager; + + private constructor(manager: CmsModelManager) { + if (!manager.model.tags?.includes(CMS_MODEL_SINGLETON_TAG)) { + throw new WebinyError({ + message: "Model is not marked as singular.", + code: "MODEL_NOT_MARKED_AS_SINGULAR", + data: { + model: manager.model + } + }); + } + this.manager = manager; + } + + public async update( + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ): Promise> { + const entry = await this.get(); + + return await this.manager.update(entry.id, data, options); + } + + public async get(): Promise> { + const id = createCacheKey(this.manager.model.modelId, { + algorithm: "sha256", + encoding: "hex" + }); + try { + return await this.manager.get(`${id}#0001`); + } catch { + return await this.manager.create( + { + id + }, + { + skipValidators: ["required"] + } + ); + } + } + + public static create( + manager: CmsModelManager + ): ISingletonModelManager { + return new SingletonModelManager(manager); + } +} diff --git a/packages/api-headless-cms/src/modelManager/index.ts b/packages/api-headless-cms/src/modelManager/index.ts index ff64efa8202..bcc722b4678 100644 --- a/packages/api-headless-cms/src/modelManager/index.ts +++ b/packages/api-headless-cms/src/modelManager/index.ts @@ -1,5 +1,6 @@ import { CmsModelManager, ModelManagerPlugin } from "~/types"; import { DefaultCmsModelManager } from "./DefaultCmsModelManager"; +export * from "./SingletonModelManager"; const plugin: ModelManagerPlugin = { type: "cms-content-model-manager", diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 5b15e6b46c1..1ee778ca5b4 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -16,6 +16,7 @@ import { CmsModelField, CmsModelFieldValidation, CmsModelUpdateInput } from "./m import { CmsModel, CmsModelCreateFromInput, CmsModelCreateInput } from "./model"; import { CmsGroup } from "./modelGroup"; import { CmsIdentity } from "./identity"; +import { ISingletonModelManager } from "~/modelManager"; export interface CmsError { message: string; @@ -693,6 +694,7 @@ export interface CmsEntryUniqueValue { * @category CmsModel */ export interface CmsModelManager { + model: CmsModel; /** * List only published entries in the content model. */ @@ -716,11 +718,18 @@ export interface CmsModelManager { /** * Create an entry. */ - create(data: CreateCmsEntryInput & I): Promise>; + create( + data: CreateCmsEntryInput & I, + options?: CreateCmsEntryOptionsInput + ): Promise>; /** * Update an entry. */ - update(id: string, data: UpdateCmsEntryInput): Promise>; + update( + id: string, + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ): Promise>; /** * Delete an entry. */ @@ -881,6 +890,12 @@ export interface CmsModelContext { getEntryManager( model: CmsModel | string ): Promise>; + /** + * A model manager for a model which has a single entry. + */ + getSingletonEntryManager( + model: CmsModel | string + ): Promise>; /** * Get all content model managers mapped by modelId. * @see CmsModelManager diff --git a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts index 14cb1f7aaea..09d2d224d68 100644 --- a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts +++ b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts @@ -34,13 +34,15 @@ interface Params { export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { const { models, fieldTypePlugins, type, createPlugin = defaultCreatePlugin } = params; + const apiType = TYPE_MAP[type]; + const plugins: ICmsGraphQLSchemaPlugin[] = []; for (const key in fieldTypePlugins) { const fieldTypePlugin = fieldTypePlugins[key]; - if (!TYPE_MAP[type] || !fieldTypePlugin[TYPE_MAP[type]]) { + if (!apiType || !fieldTypePlugin[apiType]) { continue; } - const createSchema = fieldTypePlugin[TYPE_MAP[type]].createSchema; + const createSchema = fieldTypePlugin[apiType].createSchema; // Render gql types generated by field type plugins if (!createSchema) { continue; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts index e2ce7c2ea57..2bde41b247c 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts @@ -48,6 +48,8 @@ import { import { sortItems } from "@webiny/db-dynamodb/utils/sort"; import { PageDynamoDbElasticsearchFieldPlugin } from "~/plugins/definitions/PageDynamoDbElasticsearchFieldPlugin"; import { getClean, put } from "@webiny/db-dynamodb"; +import { shouldIgnoreEsResponseError } from "~/operations/pages/shouldIgnoreEsResponseError"; +import { logIgnoredEsResponseError } from "~/operations/pages/logIgnoredEsResponseError"; /** * This function removes attributes that were once present in the Page record, which we no longer need. @@ -793,7 +795,11 @@ export const createPageStorageOperations = ( * Do not throw the error if Elasticsearch index does not exist. * In some CRUDs we try to get list of pages but index was not created yet. */ - if (ex.message === "index_not_found_exception") { + if (shouldIgnoreEsResponseError(ex)) { + logIgnoredEsResponseError({ + error: ex, + indexName: esConfig.index + }); return { items: [], meta: { @@ -884,6 +890,13 @@ export const createPageStorageOperations = ( } return tags.buckets.map(item => item.key); } catch (ex) { + if (shouldIgnoreEsResponseError(ex)) { + logIgnoredEsResponseError({ + error: ex, + indexName: esConfig.index + }); + return []; + } throw new WebinyError( ex.message || "Could not list tags by given parameters.", ex.code || "LIST_TAGS_ERROR", diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts new file mode 100644 index 00000000000..a35151ea4d6 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/logIgnoredEsResponseError.ts @@ -0,0 +1,23 @@ +import WebinyError from "@webiny/error"; + +interface LogIgnoredElasticsearchExceptionParams { + error: WebinyError; + indexName: string; +} + +export const logIgnoredEsResponseError = (params: LogIgnoredElasticsearchExceptionParams) => { + const { error, indexName } = params; + if (process.env.DEBUG !== "true") { + return; + } + console.log(`Ignoring Elasticsearch response error: ${error.message}`, { + usedIndexName: indexName, + error: { + ...error, + message: error.message, + code: error.code, + data: error.data, + stack: error.stack + } + }); +}; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts new file mode 100644 index 00000000000..9455112cf02 --- /dev/null +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/shouldIgnoreEsResponseError.ts @@ -0,0 +1,10 @@ +import WebinyError from "@webiny/error"; + +const IGNORED_ES_SEARCH_EXCEPTIONS = [ + "index_not_found_exception", + "search_phase_execution_exception" +]; + +export const shouldIgnoreEsResponseError = (error: WebinyError) => { + return IGNORED_ES_SEARCH_EXCEPTIONS.includes(error.message); +}; diff --git a/packages/api-serverless-cms/src/tenancy/InstallTenant.ts b/packages/api-serverless-cms/src/tenancy/InstallTenant.ts index 8783e422e85..e12057f34d7 100644 --- a/packages/api-serverless-cms/src/tenancy/InstallTenant.ts +++ b/packages/api-serverless-cms/src/tenancy/InstallTenant.ts @@ -59,50 +59,99 @@ export class InstallTenant { await this.context.tenancy.setVersion(this.context.WEBINY_VERSION); // SECURITY: Create initial security groups. - await this.context.security.install(); + await this.runOrThrow(async () => { + const isInstalled = await this.context.security.getVersion(); + if (!isInstalled) { + await this.context.security.install(); + } + }, "SECURITY_INSTALL"); // ADMIN USERS: Optionally, create an admin user for this tenant. - if (config.adminUsers) { - await this.context.adminUsers.install(config.adminUsers); - } else { - // We always mark `adminUsers` as installed, regardless of the config. - await this.context.adminUsers.setVersion(this.context.WEBINY_VERSION); - } + await this.runOrThrow(async () => { + const isInstalled = await this.context.adminUsers.getVersion(); + if (isInstalled) { + return; + } + + if (config.adminUsers) { + await this.context.adminUsers.install(config.adminUsers); + } else { + // We always mark `adminUsers` as installed, regardless of the config. + await this.context.adminUsers.setVersion(this.context.WEBINY_VERSION); + } + }, "ADMIN_USERS_INSTALL"); // I18N: Create a default locale. - await this.context.i18n.system.installSystem({ - code: config.i18n.defaultLocaleCode - }); + await this.runOrThrow(async () => { + const isInstalled = await this.context.i18n.system.getSystemVersion(); + if (isInstalled) { + return; + } + + await this.context.i18n.system.installSystem({ + code: config.i18n.defaultLocaleCode + }); + }, "I18N_INSTALL"); // CMS - await this.context.cms.installSystem(); + await this.runOrThrow(async () => { + const isInstalled = await this.context.cms.getSystemVersion(); + if (isInstalled) { + return; + } + await this.context.cms.installSystem(); + }, "CMS_INSTALL"); // FILE MANAGER: Create File Manager settings. const srcPrefix = config.fileManager ? `${new URL(config.fileManager.assetDeliveryDomain).origin}/files/` : fmSettings?.srcPrefix; - await this.context.fileManager.install({ srcPrefix: srcPrefix || "" }); + await this.runOrThrow(async () => { + const isInstalled = await this.context.fileManager.getVersion(); + if (isInstalled) { + return; + } + + await this.context.fileManager.install({ srcPrefix: srcPrefix || "" }); + }, "FILE_MANAGER_INSTALL"); // PAGE BUILDER: Create Page Builder settings. - await this.context.pageBuilder.installSystem({ - name: config.pageBuilder?.websiteName ?? tenant.name, - insertDemoData: config.pageBuilder?.insertDemoData ?? false - }); + await this.runOrThrow(async () => { + const isInstalled = await this.context.pageBuilder.getSystemVersion(); + if (isInstalled) { + return; + } + await this.context.pageBuilder.installSystem({ + name: config.pageBuilder?.websiteName ?? tenant.name, + insertDemoData: config.pageBuilder?.insertDemoData ?? false + }); + }, "PAGE_BUILDER_INSTALL"); // FORM BUILDER - await this.context.formBuilder.installSystem({}); - } catch (e) { + await this.runOrThrow(async () => { + const isInstalled = await this.context.formBuilder.getSystemVersion(); + if (isInstalled) { + return; + } + await this.context.formBuilder.installSystem({}); + }, "FORM_BUILDER_INSTALL"); + } finally { + this.context.tenancy.setCurrentTenant(currentTenant); + } + } + + private async runOrThrow(cb: () => Promise, errorCode: string): Promise { + try { + await cb(); + } catch (err) { throw new Error({ - message: e.message, - code: "INSTALL_TENANT", + message: err.message, + code: `INSTALL_TENANT:${errorCode}`, data: { - config, - error: e + error: err } }); - } finally { - this.context.tenancy.setCurrentTenant(currentTenant); } } } diff --git a/packages/app-aco/src/contexts/app.tsx b/packages/app-aco/src/contexts/app.tsx index 43843ab4cb2..e3afcbbb39f 100644 --- a/packages/app-aco/src/contexts/app.tsx +++ b/packages/app-aco/src/contexts/app.tsx @@ -60,7 +60,7 @@ interface CreateAppParams { getFields?: () => AcoModelField[]; } -const createApp = (data: CreateAppParams): AcoApp => { +export const createAppFromModel = (data: CreateAppParams): AcoApp => { return { ...data, getFields: @@ -138,7 +138,7 @@ export const AcoAppProvider = ({ ...prev, loading: false, model: inputModel, - app: createApp({ + app: createAppFromModel({ id, model: inputModel, getFields diff --git a/packages/app-admin-auth0/package.json b/packages/app-admin-auth0/package.json index 33c7ad89354..f0597333fa0 100644 --- a/packages/app-admin-auth0/package.json +++ b/packages/app-admin-auth0/package.json @@ -21,6 +21,7 @@ "@webiny/app-tenant-manager": "0.0.0", "@webiny/form": "0.0.0", "@webiny/plugins": "0.0.0", + "@webiny/react-router": "0.0.0", "@webiny/ui": "0.0.0", "@webiny/validation": "0.0.0", "apollo-client": "^2.6.10", diff --git a/packages/app-admin-auth0/src/Auth0.tsx b/packages/app-admin-auth0/src/Auth0.tsx index 93aeadd64bb..9d4909e049c 100644 --- a/packages/app-admin-auth0/src/Auth0.tsx +++ b/packages/app-admin-auth0/src/Auth0.tsx @@ -3,22 +3,13 @@ import gql from "graphql-tag"; import { useApolloClient } from "@apollo/react-hooks"; import get from "lodash/get"; import { LoginScreenRenderer, useTenancy, useTags } from "@webiny/app-serverless-cms"; -import { - createAuthentication, - Auth0Options, - CreateAuthenticationConfig, - OnLogout -} from "./createAuthentication"; +import { createAuthentication, CreateAuthenticationConfig } from "./createAuthentication"; import { UserMenuModule } from "~/modules/userMenu"; import { AppClientModule } from "~/modules/appClient"; import { NotAuthorizedError } from "./components"; -interface AppClientIdLoaderProps { - auth0: Auth0Options; - rootAppClientId: string; +interface AppClientIdLoaderProps extends Auth0Props { children: React.ReactNode; - onLogout?: OnLogout; - onError?: CreateAuthenticationConfig["onError"]; } const GET_CLIENT_ID = gql` @@ -32,15 +23,25 @@ const GET_CLIENT_ID = gql` const AppClientIdLoader = ({ auth0, rootAppClientId, - onLogout, - onError, - children + children, + ...rest }: AppClientIdLoaderProps) => { const [loaded, setState] = useState(false); const authRef = useRef(null); const client = useApolloClient(); const { tenant, setTenant } = useTenancy(); + const setupAuthForClientId = (clientId: string) => { + console.info(`Configuring Auth0 with App Client Id "${rootAppClientId}"`); + return createAuthentication({ + ...rest, + auth0: { + ...auth0, + clientId + } + }); + }; + useEffect(() => { // Check if `tenantId` query parameter is set. const searchParams = new URLSearchParams(location.search); @@ -51,15 +52,7 @@ const AppClientIdLoader = ({ } if (tenantId === "root") { - console.info(`Configuring Auth0 with App Client Id "${rootAppClientId}"`); - authRef.current = createAuthentication({ - onLogout, - onError, - auth0: { - ...auth0, - clientId: rootAppClientId - } - }); + authRef.current = setupAuthForClientId(rootAppClientId); setState(true); return; } @@ -67,14 +60,7 @@ const AppClientIdLoader = ({ client.query({ query: GET_CLIENT_ID }).then(({ data }) => { const clientId = get(data, "tenancy.appClientId"); if (clientId) { - console.info(`Configuring Auth0 with App Client Id "${clientId}"`); - authRef.current = createAuthentication({ - onError, - auth0: { - ...auth0, - clientId - } - }); + authRef.current = setupAuthForClientId(clientId); setState(true); } else { console.warn(`Couldn't load appClientId for tenant "${tenantId}"`); @@ -96,6 +82,7 @@ const createLoginScreenPlugin = (params: Auth0Props) => { const onError = useCallback((error: Error) => { setError(error.message); + params.onError && params.onError(error); }, []); if (error && !installer) { @@ -103,11 +90,7 @@ const createLoginScreenPlugin = (params: Auth0Props) => { } return ( - + {children} ); @@ -115,12 +98,13 @@ const createLoginScreenPlugin = (params: Auth0Props) => { }); }; -export interface Auth0Props { - auth0: Auth0Options; +export type Auth0Props = Pick< + CreateAuthenticationConfig, + "auth0" | "autoLogin" | "onLogin" | "onLogout" | "onRedirect" | "onError" +> & { rootAppClientId: string; - onLogout?: OnLogout; children?: React.ReactNode; -} +}; export const Auth0 = (props: Auth0Props) => { const LoginScreenPlugin = createLoginScreenPlugin(props); diff --git a/packages/app-admin-auth0/src/components/LoginContent.tsx b/packages/app-admin-auth0/src/components/LoginContent.tsx index 86b73b654fe..0c649da7f1c 100644 --- a/packages/app-admin-auth0/src/components/LoginContent.tsx +++ b/packages/app-admin-auth0/src/components/LoginContent.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React from "react"; import { makeDecoratable } from "@webiny/app-serverless-cms"; import { CircularProgress } from "@webiny/ui/Progress"; import { alignCenter, Title } from "~/components/StyledComponents"; @@ -7,39 +7,45 @@ import { ButtonIcon, ButtonPrimary } from "@webiny/ui/Button"; import { ReactComponent as Auth0Icon } from "~/assets/icons/auth0-icon.svg"; import { useAuth0 } from "@auth0/auth0-react"; -export const LoginContent = makeDecoratable("LoginContent", () => { - const { isAuthenticated, loginWithRedirect } = useAuth0(); +export interface LoginContentProps { + isLoading: boolean; + onLogin: () => void; +} - const login = useCallback(() => { - loginWithRedirect(); - }, []); +export const LoginContent = makeDecoratable( + "LoginContent", + ({ onLogin, isLoading }: LoginContentProps) => { + const { isAuthenticated } = useAuth0(); - return ( - <> - {isAuthenticated ? ( - - ) : ( - <> - - <Typography tag={"h1"} use={"headline4"}> - Sign In - </Typography> - -
- - You will be taken to Auth0 website to complete -
- the sign in process. -
-
-
- - } /> - Sign in via Auth0 - -
- - )} - - ); -}); + return ( + <> + {isAuthenticated ? : null} + {!isAuthenticated && isLoading ? ( + + ) : null} + {!isAuthenticated && !isLoading ? ( + <> + + <Typography tag={"h1"} use={"headline4"}> + Sign In + </Typography> + +
+ + You will be taken to Auth0 website to complete +
+ the sign in process. +
+
+
+ + } /> + Sign in via Auth0 + +
+ + ) : null} + + ); + } +); diff --git a/packages/app-admin-auth0/src/createAuthentication.tsx b/packages/app-admin-auth0/src/createAuthentication.tsx index 450564b28c6..dde4dc3f4b6 100644 --- a/packages/app-admin-auth0/src/createAuthentication.tsx +++ b/packages/app-admin-auth0/src/createAuthentication.tsx @@ -1,14 +1,23 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { setContext } from "apollo-link-context"; import ApolloClient from "apollo-client"; import { DocumentNode } from "graphql"; import { useApolloClient } from "@apollo/react-hooks"; -import { useAuth0, Auth0Provider, Auth0ProviderOptions, LogoutOptions } from "@auth0/auth0-react"; +import { + useAuth0, + Auth0Provider, + Auth0ProviderOptions, + LogoutOptions, + Auth0ContextInterface, + AppState, + User +} from "@auth0/auth0-react"; import { plugins } from "@webiny/plugins"; import { ApolloLinkPlugin } from "@webiny/app/plugins/ApolloLinkPlugin"; import { useSecurity } from "@webiny/app-serverless-cms"; import { useTenancy, withTenant } from "@webiny/app-tenancy"; import { SecurityPermission } from "@webiny/app-security/types"; +import { useRouter, UseHistory } from "@webiny/react-router"; import { createGetIdentityData, GetIdentityDataCallable, @@ -21,12 +30,24 @@ export type Auth0Options = Auth0ProviderOptions; export type OnLogout = (logout: (options?: LogoutOptions) => Promise) => void; +interface OnRedirectParams { + history: UseHistory; + appState?: AppState; + user?: User; +} + +export type OnRedirect = (params: OnRedirectParams) => void; +export type OnLogin = (auth0: Auth0ContextInterface) => void; + export interface CreateAuthenticationConfig { + auth0: Auth0Options; getIdentityData?: GetIdentityDataCallable; loginMutation?: DocumentNode; + onLogin?: OnLogin; onLogout?: OnLogout; + onRedirect?: OnRedirect; onError?(error: Error): void; - auth0: Auth0Options; + autoLogin?: boolean | (() => boolean); } export interface AuthenticationProps { @@ -52,10 +73,25 @@ const validatePermissions = (permissions: SecurityPermission[]) => { const defaultLogout: OnLogout = logout => logout(); +const defaultRedirect: OnRedirect = ({ appState, history }) => { + if (appState?.returnTo) { + history.push(appState.returnTo); + } +}; + +const defaultLogin: OnLogin = auth0 => { + auth0.loginWithRedirect({ + appState: { returnTo: window.location.pathname + window.location.search } + }); +}; + export const createAuthentication = ({ auth0, onError, + autoLogin = false, + onLogin = defaultLogin, onLogout = defaultLogout, + onRedirect = defaultRedirect, ...config }: CreateAuthenticationConfig) => { const withGetIdentityData = ( @@ -71,8 +107,17 @@ export const createAuthentication = ({ }; const Authentication = ({ getIdentityData, children }: AuthenticationProps) => { - const { isAuthenticated, isLoading, getIdTokenClaims, getAccessTokenSilently, logout } = - useAuth0(); + const auth0Context = useAuth0(); + const [loggingIn, setLoggingIn] = useState(false); + const { + isAuthenticated, + isLoading: auth0Loading, + getIdTokenClaims, + getAccessTokenSilently, + logout + } = auth0Context; + + const isLoading = auth0Loading || loggingIn; const apolloClient = useApolloClient(); const { setIdentity, identity, setIdTokenProvider } = useSecurity(); @@ -123,9 +168,10 @@ export const createAuthentication = ({ const loginSilently = async () => { try { + setLoggingIn(true); await getAccessTokenSilently(); - } catch { - // Ignore error; it simply means the user is not logged in. + } finally { + setLoggingIn(false); } }; @@ -168,6 +214,23 @@ export const createAuthentication = ({ } }; + const login = () => { + setLoggingIn(true); + onLogin(auth0Context); + }; + + const restoreSessionOrLogin = async () => { + await loginSilently(); + }; + + const shouldLogin = () => { + if (typeof autoLogin === "function") { + return autoLogin(); + } + + return autoLogin; + }; + useEffect(() => { // Call Webiny to fetch the identity information. if (isAuthenticated) { @@ -176,9 +239,12 @@ export const createAuthentication = ({ return; } - // Try to restore user's session. if (!isAuthenticated && !isLoading) { - loginSilently(); + if (auth0.cacheLocation === "localstorage") { + restoreSessionOrLogin(); + } else if (shouldLogin()) { + login(); + } } }, [isAuthenticated, isLoading]); @@ -188,7 +254,7 @@ export const createAuthentication = ({ return ( - + ); }; @@ -197,10 +263,15 @@ export const createAuthentication = ({ const LoginWidget = withGetIdentityData(withTenant(Authentication)); return function Authentication({ children }: PropsWithChildren) { + const { history } = useRouter(); + return ( { + onRedirect({ appState, user, history }); + }} {...auth0} authorizationParams={{ redirect_uri: window.location.origin, diff --git a/packages/app-admin-auth0/tsconfig.build.json b/packages/app-admin-auth0/tsconfig.build.json index 80d92a5a3b4..0678df3f3f3 100644 --- a/packages/app-admin-auth0/tsconfig.build.json +++ b/packages/app-admin-auth0/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "../app-tenant-manager/tsconfig.build.json" }, { "path": "../form/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, + { "path": "../react-router/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../validation/tsconfig.build.json" } ], diff --git a/packages/app-admin-auth0/tsconfig.json b/packages/app-admin-auth0/tsconfig.json index 1fef26bec30..a6a9d6e0a39 100644 --- a/packages/app-admin-auth0/tsconfig.json +++ b/packages/app-admin-auth0/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../app-tenant-manager" }, { "path": "../form" }, { "path": "../plugins" }, + { "path": "../react-router" }, { "path": "../ui" }, { "path": "../validation" } ], @@ -36,6 +37,8 @@ "@webiny/form": ["../form/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], + "@webiny/react-router/*": ["../react-router/src/*"], + "@webiny/react-router": ["../react-router/src"], "@webiny/ui/*": ["../ui/src/*"], "@webiny/ui": ["../ui/src"], "@webiny/validation/*": ["../validation/src/*"], diff --git a/packages/app-file-manager/src/components/FileDetails/components/Extensions.tsx b/packages/app-file-manager/src/components/FileDetails/components/Extensions.tsx index a8439b78295..ca5b35e3626 100644 --- a/packages/app-file-manager/src/components/FileDetails/components/Extensions.tsx +++ b/packages/app-file-manager/src/components/FileDetails/components/Extensions.tsx @@ -4,7 +4,7 @@ import { CompositionScope } from "@webiny/app-admin"; import { CmsModel } from "@webiny/app-headless-cms/types"; import { ModelProvider } from "@webiny/app-headless-cms/admin/components/ModelProvider"; import { Fields } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/Fields"; -import { Bind, BindComponentProps } from "@webiny/form"; +import { Bind, BindPrefix } from "@webiny/form"; const HideEmptyCells = styled.div` .mdc-layout-grid__cell:empty { @@ -16,13 +16,6 @@ interface ExtensionsProps { model: CmsModel; } -function BindWithPrefix(props: BindComponentProps) { - return ( - - {props.children} - - ); -} export const Extensions = ({ model }: ExtensionsProps) => { const extensionsField = useMemo(() => { return model.fields.find(f => f.fieldId === "extensions"); @@ -42,15 +35,17 @@ export const Extensions = ({ model }: ExtensionsProps) => { return ( - - - + + + + + ); diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/TagsList.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/TagsList.tsx index 53dfd730879..ea6809ce790 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/TagsList.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/TagsList.tsx @@ -3,10 +3,8 @@ import { Loader } from "@webiny/app-aco"; import { Empty } from "./Empty"; import { Tag } from "./Tag"; import { TagItem } from "@webiny/app-aco/types"; -import { Select } from "@webiny/ui/Select"; import { useFileManagerView } from "~/modules/FileManagerRenderer/FileManagerViewProvider"; -import { Typography } from "@webiny/ui/Typography"; -import { TagListWrapper } from "./styled"; +import { TagListWrapper, TagsFilterSelect, TagsTitle } from "./styled"; interface TagListProps { loading: boolean; @@ -59,15 +57,14 @@ export const TagsList = ({ return ( <> - Filter by tag + Filter by tag {tags.length > 1 ? ( - { if (!value) { if (!bind.value) { @@ -120,7 +114,7 @@ export const DateTimeWithTimezone = ({ bind, trailingIcon, field }: DateTimeWith return bind.onChange(""); } return bind.onChange( - `${date || getCurrentDate()}T${value}${timezone || defaultTimeZone}` + `${date || getCurrentDate()}T${getHHmmss(value)}${timezone}` ); } }} @@ -135,7 +129,7 @@ export const DateTimeWithTimezone = ({ bind, trailingIcon, field }: DateTimeWith { if (!value) { if (!bind.value) { @@ -109,7 +104,7 @@ export const DateTimeWithoutTimezone = ({ } return bind.onChange(""); } - return bind.onChange(`${date || getCurrentDate()} ${value}`); + return bind.onChange(`${date || getCurrentDate()} ${getHHmmss(value)}`); } }} field={{ diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/utils.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/utils.tsx index f145ec76a1a..28d1bb6722c 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/utils.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/utils.tsx @@ -58,6 +58,22 @@ export const getCurrentDate = (date?: Date): string => { return `${year}-${month}-${day}`; }; +export const getHHmm = (time?: string) => { + if (!time) { + return ""; + } + const parsableTime = time || getCurrentLocalTime(); + return parsableTime.split(":").slice(0, 2).join(":"); +}; + +// Ensure a valid HH:mm:ss string, ending with :00 for seconds. +export const getHHmmss = (time?: string) => { + const parsableTime = time || getCurrentLocalTime(); + const parts = [...parsableTime.split(":").slice(0, 2), "00"]; + + return parts.join(":"); +}; + const deleteIconStyles = css({ width: "100% !important", height: "100% !important", diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx index 3b77d0ba06b..113d99f4911 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx @@ -17,11 +17,19 @@ import { import { useCms } from "~/admin/hooks"; import { FullWidthDialog } from "./dialog/Dialog"; import { NavigateFolderProvider as AbstractNavigateFolderProvider } from "@webiny/app-aco/contexts/navigateFolder"; +import { SearchRecordsProvider } from "@webiny/app-aco/contexts/records"; import { FolderTree, useNavigateFolder } from "@webiny/app-aco"; import styled from "@emotion/styled"; import { Elevation } from "@webiny/ui/Elevation"; import { SplitView, LeftPanel, RightPanel } from "@webiny/app-admin/components/SplitView"; import { CircularProgress } from "@webiny/ui/Progress"; +import { usePersistEntry } from "~/admin/hooks/usePersistEntry"; +import { + AcoAppContext, + AcoAppProviderContext, + createAppFromModel +} from "@webiny/app-aco/contexts/app"; +import { DialogsProvider } from "@webiny/app-admin"; const t = i18n.ns("app-headless-cms/admin/fields/ref"); @@ -77,6 +85,7 @@ interface EntryFormProps { const EntryForm = ({ onCreate, setSaveEntry }: EntryFormProps) => { const { contentModel, loading } = useContentEntry(); + const { persistEntry } = usePersistEntry({ addItemToListCache: false }); const { currentFolderId, navigateToFolder } = useNavigateFolder(); return ( @@ -96,9 +105,9 @@ const EntryForm = ({ onCreate, setSaveEntry }: EntryFormProps) => { {loading ? : null} onCreate(entry)} entry={{}} - addEntryToListCache={false} + persistEntry={persistEntry} + onAfterCreate={entry => onCreate(entry)} setSaveEntry={setSaveEntry} /> @@ -157,22 +166,46 @@ export const NewReferencedEntryDialog = ({ }, [onChange, model] ); + if (!model) { return null; } + const acoAppContext: AcoAppProviderContext = { + app: createAppFromModel({ + model, + id: `cms:${model.modelId}` + }), + mode: "cms", + client: apolloClient, + model, + folderIdPath: "wbyAco_location.folderId", + folderIdInPath: "wbyAco_location.folderId_in", + loading: false + }; + return ( - - - - - + + + + + + + + + + + - + ); }; diff --git a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx index 34687e43524..b54022b1f57 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx @@ -5,12 +5,13 @@ import { validation, ValidationError } from "@webiny/validation"; import { Cell, Grid } from "@webiny/ui/Grid"; import { MultiAutoComplete } from "@webiny/ui/AutoComplete"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import { CmsModelFieldTypePlugin, CmsModel } from "~/types"; +import { CmsModel, CmsModelFieldTypePlugin } from "~/types"; import { ReactComponent as RefIcon } from "./icons/round-link-24px.svg"; import { i18n } from "@webiny/app/i18n"; import { Bind, BindComponentRenderProp, useForm } from "@webiny/form"; -import { useQuery, useModel } from "~/admin/hooks"; +import { useModel, useQuery } from "~/admin/hooks"; import { renderInfo } from "./ref/renderInfo"; +import { CMS_MODEL_SINGLETON_TAG } from "@webiny/app-headless-cms-common"; const t = i18n.ns("app-headless-cms/admin/fields"); @@ -34,9 +35,18 @@ const RefFieldSettings = () => { // Format options for the Autocomplete component. const options = useMemo(() => { const models = get(data, "listContentModels.data", []) as CmsModel[]; - return models.map(model => { - return { id: model.modelId, name: model.name }; - }); + return ( + models + /** + * Remove singleton models from the list of options. + */ + .filter(model => { + return !model.tags?.includes(CMS_MODEL_SINGLETON_TAG); + }) + .map(model => { + return { id: model.modelId, name: model.name }; + }) + ); }, [data]); const atLeastOneItem = useCallback(async (value: Pick) => { diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx index be8b7c23372..a6dbc26d84f 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx @@ -32,6 +32,7 @@ import { Ref } from "~/admin/components/ContentEntries/Filters/RefFieldRenderer" import { ShowConfirmationOnDelete } from "~/admin/components/Decorators/ShowConfirmationOnDelete"; import { ShowConfirmationOnPublish } from "~/admin/components/Decorators/ShowConfirmationOnPublish"; import { ShowConfirmationOnUnpublish } from "~/admin/components/Decorators/ShowConfirmationOnUnpublish"; +import { ShowConfirmationOnDeleteRevision } from "~/admin/components/Decorators/ShowConfirmationOnDeleteRevision"; const { Browser } = ContentEntryListConfig; const { Actions } = ContentEntryEditorConfig; @@ -106,6 +107,7 @@ export const ContentEntriesModule = () => { + } /> } /> } /> diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx index d4bf9f68c25..5f625d758e3 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx @@ -4,10 +4,12 @@ import styled from "@emotion/styled"; import { Tab, Tabs } from "@webiny/ui/Tabs"; import { Elevation } from "@webiny/ui/Elevation"; import { CircularProgress } from "@webiny/ui/Progress"; +import { makeDecoratable } from "@webiny/app"; import { RevisionsList } from "./ContentEntry/RevisionsList/RevisionsList"; import { useContentEntry } from "./hooks/useContentEntry"; +import { Header } from "~/admin/components/ContentEntryForm/Header"; import { ContentEntryForm } from "~/admin/components/ContentEntryForm/ContentEntryForm"; -import { makeDecoratable } from "@webiny/app"; +import { usePersistEntry } from "~/admin/hooks/usePersistEntry"; const DetailsContainer = styled("div")({ height: "calc(100% - 10px)", @@ -44,6 +46,7 @@ declare global { export const ContentEntry = makeDecoratable("ContentEntry", () => { const { loading, entry, activeTab, setActiveTab } = useContentEntry(); + const { persistEntry } = usePersistEntry({ addItemToListCache: true }); return ( @@ -57,7 +60,11 @@ export const ContentEntry = makeDecoratable("ContentEntry", () => { {loading && } - + } + /> diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx index ff1277ad928..a09f252d4ef 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx @@ -1,25 +1,22 @@ -import React, { useEffect, useMemo, useState } from "react"; -import get from "lodash/get"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "@webiny/react-router"; -import { useSnackbar, useIsMounted } from "@webiny/app-admin"; +import { useIsMounted, useSnackbar } from "@webiny/app-admin"; import { useCms, useQuery } from "~/admin/hooks"; import { ContentEntriesContext } from "~/admin/views/contentEntries/ContentEntriesContext"; import { useContentEntries } from "~/admin/views/contentEntries/hooks/useContentEntries"; import { CmsContentEntry, CmsContentEntryRevision } from "~/types"; import { parseIdentifier } from "@webiny/utils"; import { - CmsEntriesListRevisionsQueryResponse, - CmsEntriesListRevisionsQueryVariables, CmsEntryGetQueryResponse, CmsEntryGetQueryVariables, - createReadQuery, - createRevisionsQuery + createReadQuery } from "@webiny/app-headless-cms-common"; import { getFetchPolicy } from "~/utils/getFetchPolicy"; import { useRecords } from "@webiny/app-aco"; import * as Cms from "~/admin/contexts/Cms"; import { useMockRecords } from "./useMockRecords"; import { ROOT_FOLDER } from "~/admin/constants"; +import { OperationError } from "~/admin/contexts/Cms"; interface UpdateListCacheOptions { options?: { @@ -34,6 +31,14 @@ export type UpdateEntryRevisionParams = Omit; export type UnpublishEntryRevisionParams = Omit; export type DeleteEntryParams = Omit; +export type DeleteEntryRevisionParams = DeleteEntryParams; + +export interface DeleteEntryRevisionOperationSuccess { + newLatestRevision: CmsContentEntryRevision; + error?: never; +} + +export type DeleteEntryRevisionResponse = OperationError | DeleteEntryRevisionOperationSuccess; export interface ContentEntryCrud { getEntry: (params: GetEntryParams) => Promise; @@ -51,6 +56,9 @@ export interface ContentEntryCrud { params: UnpublishEntryRevisionParams ) => Promise; deleteEntry: (params: DeleteEntryParams) => Promise; + deleteEntryRevision: ( + params: DeleteEntryRevisionParams + ) => Promise; } export interface ContentEntryContext extends ContentEntriesContext, ContentEntryCrud { @@ -58,7 +66,9 @@ export interface ContentEntryContext extends ContentEntriesContext, ContentEntry loading: boolean; revisions: CmsContentEntryRevision[]; refetchContent: () => void; + setActiveTab(index: number): void; + activeTab: number; showEmptyView: boolean; } @@ -108,6 +118,7 @@ export const ContentEntryProvider = ({ const { isMounted } = useIsMounted(); const [activeTab, setActiveTab] = useState(0); const [entry, setEntry] = useState(); + const [revisions, setRevisions] = useState([]); const { contentModel: model, canCreate } = useContentEntries(); const { history } = useRouter(); const { showSnackbar } = useSnackbar(); @@ -118,6 +129,25 @@ export const ContentEntryProvider = ({ const [isLoading, setLoading] = useState(false); const contentEntryProviderProps = useContentEntryProviderProps(); + const updateRevisionInRevisionsCache = useCallback( + (updatedRevisions: CmsContentEntryRevision | CmsContentEntryRevision[]) => { + const updatedRevisionsArray = Array.isArray(updatedRevisions) + ? updatedRevisions + : [updatedRevisions]; + + setRevisions(revisions => { + return revisions.map(revision => { + const updatedRevision = updatedRevisionsArray.find( + updatedRevision => updatedRevision.id === revision.id + ); + + return updatedRevision || revision; + }); + }); + }, + [] + ); + const newEntry = typeof isNewEntry === "function" ? isNewEntry() : contentEntryProviderProps.isNewEntry(); const contentId = @@ -147,12 +177,6 @@ export const ContentEntryProvider = ({ }; }, [model.modelId]); - const { GET_REVISIONS } = useMemo(() => { - return { - GET_REVISIONS: createRevisionsQuery(model) - }; - }, [model.modelId]); - let variables: CmsEntryGetQueryVariables | undefined; if (version === null && entryId) { variables = { @@ -184,26 +208,23 @@ export const ContentEntryProvider = ({ } }); - const getRevisions = useQuery< - CmsEntriesListRevisionsQueryResponse, - CmsEntriesListRevisionsQueryVariables - >(GET_REVISIONS, { - variables: { - id: entryId as string - }, - skip: !entryId - }); - - const loading = isLoading || loadEntry.loading || getRevisions.loading; + const loading = isLoading || loadEntry.loading; useEffect(() => { - if (getRevisions.loading || !entryId) { + if (!entryId) { return; } - getRevisions.refetch({ + + cms.listEntryRevisions({ + model, id: entryId + }).then(response => { + if (response.error) { + return; + } + setRevisions(response.revisions); }); - }, [revisionId, getRevisions]); + }, [entryId]); // CRUD methods const getEntry: ContentEntryCrud["getEntry"] = async ({ id }) => { @@ -240,6 +261,7 @@ export const ContentEntryProvider = ({ setLoading(false); if (response.entry) { setEntry(response.entry); + setRevisions([response.entry, ...revisions]); updateRecordInCache(response.entry); } return response; @@ -252,6 +274,7 @@ export const ContentEntryProvider = ({ if (response.entry) { setEntry(response.entry); updateRecordInCache(response.entry); + updateRevisionInRevisionsCache(response.entry); } return response; }; @@ -262,11 +285,49 @@ export const ContentEntryProvider = ({ return response; }; + const deleteEntryRevision: ContentEntryCrud["deleteEntryRevision"] = async params => { + const response = await cms.deleteEntry({ model, ...params }); + if (typeof response === "object" && response.error) { + return response; + } + + const updatedRevisionsList = revisions.filter(rev => rev.id !== params.id); + setRevisions(updatedRevisionsList); + + const [newLatestRevision] = updatedRevisionsList; + + if (newLatestRevision) { + updateRecordInCache(newLatestRevision); + } else { + removeRecordFromCache(params.id); + } + + return { newLatestRevision }; + }; + const publishEntryRevision: ContentEntryCrud["publishEntryRevision"] = async params => { const response = await cms.publishEntryRevision({ model, ...params }); if (response.entry) { setEntry(response.entry); updateRecordInCache(response.entry); + + const revisionsToUpdateInRevisionsCache: CmsContentEntryRevision[] = [response.entry]; + + const previousPublishedRevision = revisions.find( + rev => rev.meta.status === "published" + ); + + if (previousPublishedRevision) { + revisionsToUpdateInRevisionsCache.push({ + ...previousPublishedRevision, + meta: { + ...previousPublishedRevision.meta, + status: "unpublished" + } + }); + } + + updateRevisionInRevisionsCache(revisionsToUpdateInRevisionsCache); } return response; }; @@ -276,6 +337,7 @@ export const ContentEntryProvider = ({ if (response.entry) { setEntry(response.entry); updateRecordInCache(response.entry); + updateRevisionInRevisionsCache(response.entry); } return response; }; @@ -288,11 +350,12 @@ export const ContentEntryProvider = ({ createEntry, createEntryRevisionFrom, deleteEntry, + deleteEntryRevision, entry: (entry || {}) as CmsContentEntry, loading, publishEntryRevision, refetchContent: loadEntry.refetch, - revisions: get(getRevisions, "data.revisions.data") || [], + revisions, setActiveTab, showEmptyView: !newEntry && !loading && !revisionId, unpublishEntryRevision, diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/PublishEntryRevisionListItem.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/PublishEntryRevisionListItem.tsx index b132c47284d..1fe74526577 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/PublishEntryRevisionListItem.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/PublishEntryRevisionListItem.tsx @@ -13,7 +13,7 @@ const PublishEntryRevisionListItemComponent = () => { } /> - {t`Publish`} + {t`Publish revision`} ); }; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/RevisionListItem.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/RevisionListItem.tsx index 82081997775..f82d2ce3d2a 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/RevisionListItem.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/RevisionListItem.tsx @@ -33,7 +33,7 @@ const t = i18n.ns("app-headless-cms/admin/plugins/content-details/content-revisi const primaryColor = css({ color: "var(--mdc-theme-primary)" }); const revisionsMenu = css({ - width: 250, + width: 300, right: -105, left: "auto !important" }); @@ -91,7 +91,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { {t`Last modified by {author} on {time} (#{version})`({ // Added this because revisionCreatedBy can be returned as null from GraphQL. author: revision.revisionCreatedBy?.displayName, - time: , + time: , version: revision.meta.version })} @@ -110,7 +110,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`New from current`} + {t`New revision from current`} )} @@ -124,7 +124,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`Edit`} + {t`Edit revision`} )} @@ -142,7 +142,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t`Unpublish`} + {t`Unpublish revision`} )} @@ -153,7 +153,7 @@ const RevisionListItem = ({ revision }: RevisionListItemProps) => { } /> - {t` Delete`} + {t` Delete revision`} )} diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/useRevision.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/useRevision.tsx index 7bc4bece6b6..d2a03766896 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/useRevision.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionsList/useRevision.tsx @@ -17,12 +17,15 @@ export interface EditRevisionHandler { export interface DeleteRevisionHandler { (id?: string): Promise; } + export interface PublishRevisionHandler { (id?: string): Promise; } + export interface UnpublishRevisionHandler { (id?: string): Promise; } + interface UseRevisionHandlers { createRevision: CreateRevisionHandler; editRevision: EditRevisionHandler; @@ -46,12 +49,12 @@ export const useRevision = ({ revision }: UseRevisionProps) => { const { createRevision, editRevision, deleteRevision, publishRevision, unpublishRevision } = useHandlers( - { entry: revision }, + { entry: revision, contentEntryHook: contentEntry }, { createRevision: - (): CreateRevisionHandler => + ({ contentEntryHook }): CreateRevisionHandler => async (id): Promise => { - const { entry, error } = await contentEntry.createEntryRevisionFrom({ + const { entry, error } = await contentEntryHook.createEntryRevisionFrom({ id: id || revision.id }); @@ -74,31 +77,32 @@ export const useRevision = ({ revision }: UseRevisionProps) => { ); }, deleteRevision: - ({ entry }): DeleteRevisionHandler => + ({ entry, contentEntryHook }): DeleteRevisionHandler => async (id): Promise => { const revisionId = id || entry.id; - const response = await contentEntry.deleteEntry({ + + const response = await contentEntryHook.deleteEntryRevision({ id: revisionId }); - if (typeof response === "boolean") { - // Redirect to the first revision in the list of all entry revisions. - const targetRevision = contentEntry.revisions.filter( - rev => rev.id !== revisionId - )[0]; - history.push( - `/cms/content-entries/${modelId}?id=` + - encodeURIComponent(targetRevision!.id) - ); + if (typeof response === "object" && response.error) { return; } - showSnackbar(response.error.message); + const { newLatestRevision } = response; + + let redirectTarget = `/cms/content-entries/${modelId}`; + if (newLatestRevision) { + // Redirect to the first revision in the list of all entry revisions. + redirectTarget += `?id=${encodeURIComponent(newLatestRevision.id)}`; + } + + history.push(redirectTarget); }, publishRevision: - ({ entry }): PublishRevisionHandler => + ({ entry, contentEntryHook }): PublishRevisionHandler => async id => { - const response = await contentEntry.publishEntryRevision({ + const response = await contentEntryHook.publishEntryRevision({ id: id || entry.id }); @@ -117,9 +121,9 @@ export const useRevision = ({ revision }: UseRevisionProps) => { return response; }, unpublishRevision: - ({ entry }): UnpublishRevisionHandler => + ({ entry, contentEntryHook }): UnpublishRevisionHandler => async id => { - const { error } = await contentEntry.unpublishEntryRevision({ + const { error } = await contentEntryHook.unpublishEntryRevision({ id: id || entry.id }); diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntry.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntry.tsx new file mode 100644 index 00000000000..354cdfbe380 --- /dev/null +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntry.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { Elevation as BaseElevation } from "@webiny/ui/Elevation"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { ContentEntryForm } from "~/admin/components/ContentEntryForm/ContentEntryForm"; +import { makeDecoratable } from "@webiny/app"; +import { useSingletonContentEntry } from "~/admin/views/contentEntries/hooks/useSingletonContentEntry"; +import { PartialCmsContentEntryWithId } from "~/admin/contexts/Cms"; +import { SingletonHeader } from "~/admin/components/ContentEntryForm/SingletonHeader"; + +const Elevation = styled(BaseElevation)` + height: 100%; + flex: 0 0 50%; +`; + +const Container = styled.div` + display: flex; + padding: 25px; + justify-content: center; +`; + +export const SingletonContentEntry = makeDecoratable("SingletonContentEntry", () => { + const { loading, entry, updateEntry, contentModel } = useSingletonContentEntry(); + + return ( + + + {loading && } + } + entry={entry} + persistEntry={entry => + updateEntry({ entry: entry as PartialCmsContentEntryWithId }) + } + /> + + + ); +}); diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntryContext.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntryContext.tsx new file mode 100644 index 00000000000..89cbb0d6c5f --- /dev/null +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/SingletonContentEntryContext.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from "react"; +import { useSnackbar, useIsMounted } from "@webiny/app-admin"; +import { useCms } from "~/admin/hooks"; +import { useContentEntries } from "~/admin/views/contentEntries/hooks/useContentEntries"; +import { CmsContentEntry, CmsModel } from "~/types"; +import * as Cms from "~/admin/contexts/Cms"; + +export type UpdateEntryParams = Omit; + +export interface SingletonContentEntryCrud { + updateEntry: (params: UpdateEntryParams) => Promise; +} + +export interface SingletonContentEntryContext extends SingletonContentEntryCrud { + contentModel: CmsModel; + entry: CmsContentEntry; + loading: boolean; +} + +export const SingletonContentEntryContext = React.createContext< + SingletonContentEntryContext | undefined +>(undefined); + +export interface ContentEntryContextProviderProps { + children: React.ReactNode; +} + +export const SingletonContentEntryProvider = ({ children }: ContentEntryContextProviderProps) => { + const { isMounted } = useIsMounted(); + const [entry, setEntry] = useState(); + const { contentModel: model } = useContentEntries(); + const { showSnackbar } = useSnackbar(); + const cms = useCms(); + const [isLoading, setLoading] = useState(false); + + useEffect(() => { + (async () => { + setLoading(true); + const { entry, error } = await cms.getSingletonEntry({ model }); + setLoading(false); + + if (!isMounted()) { + return; + } + + if (!error) { + setEntry(entry); + return; + } + showSnackbar(error.message); + })(); + }, []); + + const updateEntry: SingletonContentEntryCrud["updateEntry"] = async params => { + setLoading(true); + const response = await cms.updateSingletonEntry({ model, ...params }); + setLoading(false); + if (response.entry) { + setEntry(response.entry); + } + return response; + }; + + const value: SingletonContentEntryContext = { + contentModel: model, + entry: (entry || {}) as CmsContentEntry, + loading: isLoading, + updateEntry + }; + + return ( + + {children} + + ); +}; + +SingletonContentEntryProvider.displayName = "SingletonContentEntryProvider"; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/SingletonContentEntryModule.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/SingletonContentEntryModule.tsx new file mode 100644 index 00000000000..8ee9dd7ba7c --- /dev/null +++ b/packages/app-headless-cms/src/admin/views/contentEntries/SingletonContentEntryModule.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { ContentEntries } from "~/admin/views/contentEntries/ContentEntries"; +import { CMS_MODEL_SINGLETON_TAG } from "@webiny/app-headless-cms-common"; +import { ContentEntriesProvider } from "~/admin/views/contentEntries/ContentEntriesContext"; +import { DialogsProvider } from "@webiny/app-admin"; +import { SingletonContentEntryProvider } from "~/admin/views/contentEntries/ContentEntry/SingletonContentEntryContext"; +import { SingletonContentEntry } from "~/admin/views/contentEntries/ContentEntry/SingletonContentEntry"; +import { useModel } from "~/admin/components/ModelProvider"; + +const ContentEntriesDecorator = ContentEntries.createDecorator(Original => { + return function ContentEntries() { + const { model } = useModel(); + + if (model.tags.includes(CMS_MODEL_SINGLETON_TAG)) { + return ( + + + + + + + + ); + } + + return ; + }; +}); + +export const SingletonContentEntryModule = () => { + return ( + <> + + + ); +}; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useSingletonContentEntry.ts b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useSingletonContentEntry.ts new file mode 100644 index 00000000000..299c1cd3afe --- /dev/null +++ b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useSingletonContentEntry.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +import { makeDecoratable } from "@webiny/app-admin"; +import { SingletonContentEntryContext } from "../ContentEntry/SingletonContentEntryContext"; + +export const useSingletonContentEntry = makeDecoratable(() => { + const context = useContext(SingletonContentEntryContext); + if (!context) { + throw Error( + `useSingletonContentEntry() hook can only be used within the SingletonContentEntryContext provider.` + ); + } + return context; +}); diff --git a/packages/app-headless-cms/src/admin/views/contentModels/ContentModelsDataList.tsx b/packages/app-headless-cms/src/admin/views/contentModels/ContentModelsDataList.tsx index 3ea13dbeee3..e52925f20e2 100644 --- a/packages/app-headless-cms/src/admin/views/contentModels/ContentModelsDataList.tsx +++ b/packages/app-headless-cms/src/admin/views/contentModels/ContentModelsDataList.tsx @@ -34,6 +34,7 @@ import { OptionsMenu } from "./OptionsMenu"; import { ReactComponent as DownloadFileIcon } from "@webiny/app-admin/assets/icons/file_download.svg"; import { ReactComponent as UploadFileIcon } from "@webiny/app-admin/assets/icons/file_upload.svg"; import { useModelExport } from "./exporting/useModelExport"; +import { CMS_MODEL_SINGLETON_TAG } from "@webiny/app-headless-cms-common"; const t = i18n.namespace("FormsApp.ContentModelsDataList"); @@ -120,7 +121,20 @@ const ContentModelsDataList = ({ const client = useApolloClient(); const { showSnackbar } = useSnackbar(); const { showConfirmation } = useConfirmationDialog({ - dataTestId: "cms-delete-content-model-dialog" + dataTestId: "cms-delete-content-model-dialog", + title: "Delete a content model" + }); + + const { showConfirmation: showConfirmationSingleton } = useConfirmationDialog({ + dataTestId: "cms-delete-singleton-content-model-dialog", + title: "Delete a single entry model", + message: ( +

+ Deleting a single entry content model will also delete the entry in the model. +
+ Are you sure you want to delete this content model? +

+ ) }); const { models, loading, refresh } = useModels(); const { canDelete, canEdit } = usePermission(); @@ -144,7 +158,11 @@ const ContentModelsDataList = ({ ); const deleteRecord = async (item: CmsModel): Promise => { - showConfirmation(async () => { + const confirmation = item.tags.includes(CMS_MODEL_SINGLETON_TAG) + ? showConfirmationSingleton + : showConfirmation; + + confirmation(async () => { await client.mutate({ mutation: GQL.DELETE_CONTENT_MODEL, variables: { modelId: item.modelId }, diff --git a/packages/app-headless-cms/src/admin/views/contentModels/NewContentModelDialog.tsx b/packages/app-headless-cms/src/admin/views/contentModels/NewContentModelDialog.tsx index c540bdd452c..f9a780911c5 100644 --- a/packages/app-headless-cms/src/admin/views/contentModels/NewContentModelDialog.tsx +++ b/packages/app-headless-cms/src/admin/views/contentModels/NewContentModelDialog.tsx @@ -6,11 +6,12 @@ import { Select } from "@webiny/ui/Select"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { CircularProgress } from "@webiny/ui/Progress"; import { validation } from "@webiny/validation"; -import { useQuery, useMutation, useApolloClient } from "../../hooks"; +import { useApolloClient, useMutation, useQuery } from "../../hooks"; import { i18n } from "@webiny/app/i18n"; import { ButtonPrimary } from "@webiny/ui/Button"; import * as UID from "@webiny/ui/Dialog"; -import { Grid, Cell } from "@webiny/ui/Grid"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { CMS_MODEL_SINGLETON_TAG } from "@webiny/app-headless-cms-common"; import { addModelToGroupCache, addModelToListCache } from "./cache"; import * as GQL from "../../viewsGraphql"; import { @@ -25,6 +26,7 @@ import { createApiNameValidator } from "~/admin/views/contentModels/helpers/apiN import { createNameValidator } from "~/admin/views/contentModels/helpers/nameValidator"; import { Checkbox } from "@webiny/ui/Checkbox"; import { IconPicker } from "~/admin/components/IconPicker"; +import { Switch } from "@webiny/ui/Switch"; const t = i18n.ns("app-headless-cms/admin/views/content-models/new-content-model-dialog"); @@ -37,6 +39,10 @@ interface CmsModelData { name: string; description: string; group: string; + singleton?: boolean; + singularApiName: string; + pluralApiName: string; + defaultFields: boolean; } const NewContentModelDialog = ({ open, onClose }: NewContentModelDialogProps) => { @@ -117,135 +123,172 @@ const NewContentModelDialog = ({ open, onClose }: NewContentModelDialogProps) => models ]); - const group = contentModelGroups.length > 0 ? contentModelGroups[0].value : null; + const group = useMemo(() => { + if (!contentModelGroups.length) { + return undefined; + } + return contentModelGroups[0]?.value; + }, [contentModelGroups]); - const onSubmit = async (data: CmsModelData) => { - setLoading(true); - await createContentModel({ - variables: { data } - }); - }; + const onSubmit = useCallback( + async (data: CmsModelData) => { + setLoading(true); + /** + * We need to make sure that tags are always an array. + * At the moment there is no tags on the CmsModelData type. + * If it is added at some point, the @ts-expect-error should be removed - it will cause TS error. + */ + // @ts-expect-error + const tags: string[] = Array.isArray(data.tags) ? data.tags : []; + /** + * If a model is a singleton, we add a special tag to it. + * + we need to put the pluralApiName to something that is not used. + */ + if (data.singleton) { + tags.push(CMS_MODEL_SINGLETON_TAG); + data.pluralApiName = `${data.singularApiName}Unused`; + } + delete data.singleton; + await createContentModel({ + variables: { + data: { + ...data, + tags + } + } + }); + }, + [loading, createContentModel] + ); return ( {open && ( -
{ - /** - * We are positive that data is CmsModelData. - */ - onSubmit(data as unknown as CmsModelData); - }} - > - {({ Bind, submit }) => ( - <> - {loading && } - {t`New Content Model`} - - - - - - - - - - - - - - - - - - - - + + + + - )} - - - - - - - - - - - { - submit(ev); - }} - > - + {t`Create Model`} - - - - )} + + + + + + + + + + + + + + + + )} + + + + + + + + + + + + + {t`Create Model`} + + + + ); + }} )}
diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx index 2596661cf90..77fb906ba43 100644 --- a/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx @@ -10,9 +10,9 @@ export const EditPage = makeDecoratable("EditPage", () => { const { page } = usePage(); const { OptionsMenuItem, OptionsMenuLink } = PageListConfig.Browser.PageAction; const { getPageEditorUrl, navigateToPageEditor } = useNavigatePage(); - const { createPageForm, loading } = useCreatePageFrom({ - page, - onSuccess: () => navigateToPageEditor(page.data.pid) + const { createPageFromMutation, loading } = useCreatePageFrom({ + page: page.data, + onSuccess: data => navigateToPageEditor(data.id) }); if (page.data.locked) { @@ -20,7 +20,7 @@ export const EditPage = makeDecoratable("EditPage", () => { } label={"Edit"} - onAction={createPageForm} + onAction={createPageFromMutation} disabled={loading} data-testid={"aco.actions.pb.page.edit"} /> @@ -31,7 +31,7 @@ export const EditPage = makeDecoratable("EditPage", () => { } label={"Edit"} - to={getPageEditorUrl(page.id)} + to={getPageEditorUrl(page.data.id)} data-testid={"aco.actions.pb.page.edit"} /> ); diff --git a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx index 0cb9ab48662..da16f3cd3de 100644 --- a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useState, useEffect, useMemo } from "react"; -import { css } from "emotion"; import styled from "@emotion/styled"; import classNames from "classnames"; import { useQuery } from "@apollo/react-hooks"; @@ -27,8 +26,10 @@ import { import * as Styled from "~/templateEditor/config/Content/BlocksBrowser/StyledComponents"; import { PbPageTemplate } from "~/types"; -const leftPanelStyle = css` +const ListContainer = styled.div` + width: 100%; height: calc(100vh - 64px); + overflow: clip; display: flex; flex-direction: column; `; @@ -91,7 +92,7 @@ const ModalTitleStyled = styled.div` `; const SearchInputWrapper = styled.div` - padding: 15px; + padding: 16px; `; const BlankTemplateButtonWrapper = styled.div` @@ -152,54 +153,58 @@ const PageTemplatesDialog = ({ onClose, onSelect, isLoading }: PageTemplatesDial return ( } onExited={onClose}> - - - - } /> - - {({ value, onChange }) => ( - onChange(ev.target.value)} - /> - )} - - - - - {filteredPageTemplates.map(template => ( - { - setActiveTemplate(template); - }} - > - - {template.title} - {template.description} - - - ))} - - - onSelect()} - data-testid={"pb-new-page-dialog-use-blank-template-btn"} + + + + + } /> + + {({ value, onChange }) => ( + onChange(ev.target.value)} + /> + )} + + + + - Use a blank page template - - + {filteredPageTemplates.map(template => ( + { + setActiveTemplate(template); + }} + > + + {template.title} + + {template.description} + + + + ))} + + + onSelect()} + data-testid={"pb-new-page-dialog-use-blank-template-btn"} + > + Use a blank page template + + + {activeTemplate && ( diff --git a/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts b/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts index 62807809a7f..d4104dc8db0 100644 --- a/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts +++ b/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts @@ -4,11 +4,11 @@ import { useSnackbar } from "@webiny/app-admin"; import { CREATE_PAGE } from "~/admin/graphql/pages"; import * as GQLCache from "~/admin/views/Pages/cache"; -import { PbPageTableItem } from "~/types"; +import { PbPageDataItem, PbPageRevision } from "~/types"; interface UseEditPageParams { - page: PbPageTableItem; - onSuccess?: () => void; + page: PbPageDataItem; + onSuccess?: (data: PbPageRevision) => void; } export const useCreatePageFrom = ({ page, onSuccess }: UseEditPageParams) => { @@ -16,8 +16,9 @@ export const useCreatePageFrom = ({ page, onSuccess }: UseEditPageParams) => { const [createPageFrom] = useMutation(CREATE_PAGE); const { showSnackbar } = useSnackbar(); - const createPageForm = useCallback(async () => { + const createPageFromMutation = useCallback(async () => { setLoading(true); + const response = await createPageFrom({ variables: { from: page.id }, update(cache, { data }) { @@ -30,18 +31,18 @@ export const useCreatePageFrom = ({ page, onSuccess }: UseEditPageParams) => { }); setLoading(false); - const { error } = response.data.pageBuilder.createPage; + const { data, error } = response.data.pageBuilder.createPage; if (error) { return showSnackbar(error.message); } if (typeof onSuccess === "function") { - onSuccess(); + onSuccess(data); } }, [page, onSuccess]); return { - createPageForm, + createPageFromMutation, loading }; }; diff --git a/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/StyledComponents.ts b/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/StyledComponents.ts index 367fc4c10bc..cedfa61fe52 100644 --- a/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/StyledComponents.ts +++ b/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/StyledComponents.ts @@ -15,16 +15,17 @@ export const List = styled("div")({ export const Input = styled("div")({ backgroundColor: "var(--mdc-theme-on-background)", position: "relative", - height: 30, - padding: 3, + height: 36, width: "100%", borderRadius: 2, "> input": { border: "none", fontSize: 16, - width: "calc(100% - 10px)", - height: "100%", - marginLeft: 50, + position: "absolute", + top: 0, + bottom: 0, + left: 36, + right: 0, backgroundColor: "transparent", outline: "none", color: "var(--mdc-theme-text-primary-on-background)" @@ -37,14 +38,13 @@ export const searchIcon = css({ position: "absolute", width: 24, height: 24, - left: 15, - top: 7 + left: 8, + top: 6 } }); export const wrapper = css({ height: "100vh", - //overflow: "scroll", backgroundColor: "var(--mdc-theme-background)" }); diff --git a/packages/cli-plugin-deploy-pulumi/commands/index.js b/packages/cli-plugin-deploy-pulumi/commands/index.js index 5ba80662399..0942cf10bbf 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/index.js +++ b/packages/cli-plugin-deploy-pulumi/commands/index.js @@ -1,5 +1,3 @@ -const { featureFlags } = require("@webiny/feature-flags"); - module.exports = [ { type: "cli-command", @@ -140,7 +138,19 @@ module.exports = [ } ); - if (featureFlags.newWatchCommand) { + let useNewWatchCommand = false; + + // We needed to add try / catch here because, in `webiny-js` repository, + // `@webiny/feature-flags` package is not available on first project build (`yarn build`). + // This logic will go away anyway, once the new watch command is fully released. + try { + const { featureFlags } = require("@webiny/feature-flags"); + useNewWatchCommand = Boolean(featureFlags.newWatchCommand); + } catch { + // Ignore. + } + + if (useNewWatchCommand) { yargs.command( "watch [folder]", `Start a new development session`, diff --git a/packages/cli-plugin-scaffold-extensions/package.json b/packages/cli-plugin-scaffold-extensions/package.json index ea86d52c95e..218c09f5faa 100644 --- a/packages/cli-plugin-scaffold-extensions/package.json +++ b/packages/cli-plugin-scaffold-extensions/package.json @@ -26,6 +26,7 @@ "case": "^1.6.3", "chalk": "^4.1.0", "execa": "^5.0.0", + "fast-glob": "^3.2.7", "glob": "^7.1.2", "load-json-file": "^6.2.0", "lodash": "^4.17.21", diff --git a/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension.ts b/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension.ts index e58bf4c4bdd..55cc5f1e822 100644 --- a/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension.ts +++ b/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension.ts @@ -10,9 +10,9 @@ import { Input } from "./types"; import { downloadFolderFromS3 } from "./downloadAndLinkExtension/downloadFolderFromS3"; import { setWebinyPackageVersions } from "~/utils/setWebinyPackageVersions"; import { runYarnInstall } from "@webiny/cli-plugin-scaffold/utils"; -import { getDownloadedExtensionType } from "~/downloadAndLinkExtension/getDownloadedExtensionType"; import chalk from "chalk"; import { Extension } from "./extensions/Extension"; +import glob from "fast-glob"; const EXTENSIONS_ROOT_FOLDER = "extensions"; @@ -43,6 +43,17 @@ const getVersionFromVersionFolders = async ( return versionToUse.replace(".0", ".x"); }; +const getExtensionPkgJsonGlobs = (extensionsFolderNames: string[]) => { + const base = [ + EXTENSIONS_ROOT_FOLDER, + extensionsFolderNames.length > 1 + ? `{${extensionsFolderNames.join()}}` + : extensionsFolderNames[0] + ].join("/"); + + return [base + "/**/package.json", base + "/package.json"]; +}; + export const downloadAndLinkExtension = async ({ input, ora, @@ -99,22 +110,17 @@ export const downloadAndLinkExtension = async ({ // Retrieve extensions folders in the root of the downloaded extension. We use this // later to run additional setup tasks on each extension. const extensionsFolderNames = await fsAsync.readdir(extensionsFolderToCopyPath); - const downloadedExtensions: Extension[] = []; - for (const extensionsFolderName of extensionsFolderNames) { - const folderPath = path.join(EXTENSIONS_ROOT_FOLDER, extensionsFolderName); - const extensionType = await getDownloadedExtensionType(folderPath); + const extensionsPkgJsonGlobs = await getExtensionPkgJsonGlobs(extensionsFolderNames); + const extensionsPkgJsonPaths = await glob(extensionsPkgJsonGlobs); - downloadedExtensions.push( - new Extension({ - name: extensionsFolderName, - type: extensionType, - location: folderPath, + const downloadedExtensions: Extension[] = []; - // We don't care about the package name here. - packageName: extensionsFolderName - }) - ); + for (const maybeExtensionPath of extensionsPkgJsonPaths) { + const maybeExtension = await Extension.fromPackageJsonPath(maybeExtensionPath); + if (maybeExtension) { + downloadedExtensions.push(maybeExtension); + } } for (const downloadedExtension of downloadedExtensions) { @@ -127,7 +133,9 @@ export const downloadAndLinkExtension = async ({ if (downloadedExtensions.length === 1) { const [downloadedExtension] = downloadedExtensions; ora.succeed( - `Extension downloaded in ${context.success.hl(downloadedExtension.getLocation())}.` + `Extension downloaded successfully in ${context.success.hl( + downloadedExtension.getLocation() + )}.` ); const nextSteps = downloadedExtension.getNextSteps(); @@ -139,7 +147,10 @@ export const downloadAndLinkExtension = async ({ }); } else { const paths = downloadedExtensions.map(ext => ext.getLocation()); - ora.succeed(`Extensions downloaded in ${context.success.hl(paths.join(", "))}.`); + ora.succeed("Multiple extensions downloaded successfully in:"); + paths.forEach(p => { + console.log(` ‣ ${context.success.hl(p)}`); + }); } } catch (e) { switch (e.code) { diff --git a/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension/getDownloadedExtensionType.ts b/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension/getDownloadedExtensionType.ts deleted file mode 100644 index 7c53dd3a625..00000000000 --- a/packages/cli-plugin-scaffold-extensions/src/downloadAndLinkExtension/getDownloadedExtensionType.ts +++ /dev/null @@ -1,19 +0,0 @@ -import loadJson from "load-json-file"; -import { PackageJson } from "@webiny/cli-plugin-scaffold/types"; -import path from "node:path"; - -export const getDownloadedExtensionType = async (downloadedExtensionRootPath: string) => { - const pkgJsonPath = path.join(downloadedExtensionRootPath, "package.json"); - const pkgJson = await loadJson(pkgJsonPath); - - const keywords = pkgJson.keywords; - if (Array.isArray(keywords)) { - for (const keyword of keywords) { - if (keyword.startsWith("webiny-extension-type:")) { - return keyword.replace("webiny-extension-type:", ""); - } - } - } - - throw new Error(`Could not determine the extension type from the downloaded extension.`); -}; diff --git a/packages/cli-plugin-scaffold-extensions/src/extensions/Extension.ts b/packages/cli-plugin-scaffold-extensions/src/extensions/Extension.ts index e60d2688f50..c062edae4c8 100644 --- a/packages/cli-plugin-scaffold-extensions/src/extensions/Extension.ts +++ b/packages/cli-plugin-scaffold-extensions/src/extensions/Extension.ts @@ -1,6 +1,12 @@ import { AbstractExtension, ExtensionTypeConstructorParams } from "./AbstractExtension"; import { AdminExtension } from "./AdminExtension"; import { ApiExtension } from "./ApiExtension"; +import { WorkspaceExtension } from "./WorkspaceExtension"; +import loadJson from "load-json-file"; +import { PackageJson } from "@webiny/cli-plugin-scaffold/types"; +import path from "path"; + +type PackageJsonPath = string; export class Extension extends AbstractExtension { extension: AbstractExtension; @@ -17,6 +23,10 @@ export class Extension extends AbstractExtension { this.extension = new ApiExtension(params); break; } + case "workspace": { + this.extension = new WorkspaceExtension(params); + break; + } default: { throw new Error(`Unknown extension type: ${params.type}`); } @@ -30,4 +40,39 @@ export class Extension extends AbstractExtension { getNextSteps(): string[] { return this.extension.getNextSteps(); } + + static async fromPackageJsonPath(pkgJsonPath: PackageJsonPath) { + const loadedPkgJson = await loadJson(pkgJsonPath); + + const extensionType = await Extension.getDownloadedExtensionType(loadedPkgJson); + if (!extensionType) { + return null; + } + + return new Extension({ + name: loadedPkgJson.name, + type: extensionType, + location: path.dirname(pkgJsonPath), + packageName: loadedPkgJson.name + }); + } + + static async getDownloadedExtensionType(pkgJson: PackageJsonPath | PackageJson) { + const loadedPkgJson = + typeof pkgJson === "string" ? await loadJson(pkgJson) : pkgJson; + + const keywords = loadedPkgJson.keywords; + // If there is no keywords, we consider the folder to be a workspace. + if (!Array.isArray(keywords)) { + return "workspace"; + } + + for (const keyword of keywords) { + if (keyword.startsWith("webiny-extension-type:")) { + return keyword.replace("webiny-extension-type:", ""); + } + } + + throw new Error(`Could not determine the extension type from the downloaded extension.`); + } } diff --git a/packages/cli-plugin-scaffold-extensions/src/extensions/WorkspaceExtension.ts b/packages/cli-plugin-scaffold-extensions/src/extensions/WorkspaceExtension.ts new file mode 100644 index 00000000000..393d05ef663 --- /dev/null +++ b/packages/cli-plugin-scaffold-extensions/src/extensions/WorkspaceExtension.ts @@ -0,0 +1,11 @@ +import { AbstractExtension } from "./AbstractExtension"; + +export class WorkspaceExtension extends AbstractExtension { + async generate() { + return Promise.resolve(); + } + + getNextSteps(): string[] { + return []; + } +} diff --git a/packages/cli-plugin-scaffold-extensions/src/generateExtension.ts b/packages/cli-plugin-scaffold-extensions/src/generateExtension.ts index c4ac79a2b16..fa15cd13e56 100644 --- a/packages/cli-plugin-scaffold-extensions/src/generateExtension.ts +++ b/packages/cli-plugin-scaffold-extensions/src/generateExtension.ts @@ -107,14 +107,6 @@ export const generateExtension = async ({ } await writeJson(packageJsonPath, packageJson); - - // Add package to workspaces - const rootPackageJsonPath = path.join(project.root, "package.json"); - const rootPackageJson = await readJson(rootPackageJsonPath); - if (!rootPackageJson.workspaces.packages.includes(location)) { - rootPackageJson.workspaces.packages.push(location); - await writeJson(rootPackageJsonPath, rootPackageJson); - } } const extension = new Extension({ @@ -126,6 +118,14 @@ export const generateExtension = async ({ await extension.generate(); + // Add package to workspaces + const rootPackageJsonPath = path.join(project.root, "package.json"); + const rootPackageJson = await readJson(rootPackageJsonPath); + if (!rootPackageJson.workspaces.packages.includes(location)) { + rootPackageJson.workspaces.packages.push(location); + await writeJson(rootPackageJsonPath, rootPackageJson); + } + // Sleep for 1 second before proceeding with yarn installation. await setTimeout(1000); diff --git a/packages/cwp-template-aws/cli/info/index.js b/packages/cwp-template-aws/cli/info/index.js index 9aa298c5c16..2e9876920b1 100644 --- a/packages/cwp-template-aws/cli/info/index.js +++ b/packages/cwp-template-aws/cli/info/index.js @@ -23,7 +23,7 @@ const getInfo = async env => { return [ "It seems none of the stacks were deployed, so no info could be provided.", `Please check if the provided environment ${env} is correct.` - ]; + ].join(" "); } const [api, admin, website] = outputs; diff --git a/packages/db-dynamodb/package.json b/packages/db-dynamodb/package.json index 0582acbc268..00ab30f0cfb 100644 --- a/packages/db-dynamodb/package.json +++ b/packages/db-dynamodb/package.json @@ -16,11 +16,11 @@ "@webiny/error": "0.0.0", "@webiny/handler-db": "0.0.0", "@webiny/plugins": "0.0.0", + "@webiny/utils": "0.0.0", "date-fns": "^2.22.1", "dot-prop": "^6.0.1", "dynamodb-toolbox": "^0.9.2", "fuse.js": "7.0.0", - "is-number": "^7.0.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/db-dynamodb/src/plugins/definitions/NumberTransformPlugin.ts b/packages/db-dynamodb/src/plugins/definitions/NumberTransformPlugin.ts deleted file mode 100644 index 8f88eadf180..00000000000 --- a/packages/db-dynamodb/src/plugins/definitions/NumberTransformPlugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - ValueTransformPlugin, - ValueTransformPluginParams, - ValueTransformPluginParamsTransformParams -} from "./ValueTransformPlugin"; -import WebinyError from "@webiny/error"; -import isNumber from "is-number"; - -const transformNumber = (params: ValueTransformPluginParamsTransformParams): number => { - const { value } = params; - const typeOf = typeof value; - /** - * Due to some internal JS stuff, we must check for a number like this. - */ - if (typeOf === "number" || isNumber(value) === true) { - return Number(value); - } - throw new WebinyError("Field value must be a number because.", "NUMBER_ERROR", { - value - }); -}; - -export class NumberTransformPlugin extends ValueTransformPlugin { - public constructor(params: Omit) { - super({ - transform: transformNumber, - ...params - }); - } -} diff --git a/packages/db-dynamodb/src/plugins/definitions/TimeTransformPlugin.ts b/packages/db-dynamodb/src/plugins/definitions/TimeTransformPlugin.ts index 5604fd235b9..0f2c28d7e54 100644 --- a/packages/db-dynamodb/src/plugins/definitions/TimeTransformPlugin.ts +++ b/packages/db-dynamodb/src/plugins/definitions/TimeTransformPlugin.ts @@ -4,19 +4,24 @@ import { ValueTransformPluginParamsTransformParams } from "./ValueTransformPlugin"; import WebinyError from "@webiny/error"; -import isNumber from "is-number"; const transformTime = (params: ValueTransformPluginParamsTransformParams): number => { const { value } = params; if (value === undefined || value === null) { - throw new WebinyError(`Time value is null or undefined`, "TIME_PARSE_ERROR", { + throw new WebinyError(`Time value is null or undefined.`, "TIME_PARSE_ERROR", { value }); + } else if (typeof value === "boolean" || value === "" || Array.isArray(value)) { + throw new WebinyError( + "Field value must be a string because field is defined as time.", + "TIME_PARSE_ERROR", + { + value + } + ); } - /** - * Due to some internal JS stuff, we must check for a number like this. - */ - if (typeof value === "number" || isNumber(value) === true) { + const converted = Number(`${value}`); + if (typeof value === "number" || isNaN(converted) === false) { return Number(value); } else if (typeof value !== "string") { throw new WebinyError( diff --git a/packages/db-dynamodb/src/plugins/filters/eq.ts b/packages/db-dynamodb/src/plugins/filters/eq.ts index f055e9a498a..918061b32e8 100644 --- a/packages/db-dynamodb/src/plugins/filters/eq.ts +++ b/packages/db-dynamodb/src/plugins/filters/eq.ts @@ -12,10 +12,10 @@ const plugin = new ValueFilterPlugin({ }); } else if (Array.isArray(compareValue) === true) { return compareValue.every((v: string) => { - return value === v; + return value == v; }); } - return value === compareValue; + return value == compareValue; } }); diff --git a/packages/db-dynamodb/src/utils/scan.ts b/packages/db-dynamodb/src/utils/scan.ts index d11df0a1db8..6feac2268f1 100644 --- a/packages/db-dynamodb/src/utils/scan.ts +++ b/packages/db-dynamodb/src/utils/scan.ts @@ -1,5 +1,6 @@ import { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb"; import { Entity, ScanOptions, Table } from "~/toolbox"; +import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils"; export type { ScanOptions }; @@ -93,11 +94,29 @@ export const scan = async (params: ScanParams): Promise> => { return convertResult(result) as ScanResponse; }; +interface ScanWithCallbackOptions { + retry?: true | ExecuteWithRetryOptions; +} + export const scanWithCallback = async ( params: ScanParams, - callback: (result: ScanResponse>) => Promise + callback: (result: ScanResponse>) => Promise, + options?: ScanWithCallbackOptions ): Promise => { - let result = await scan>(params); + // For backwards compatibility, we still allow for executing the scan without retries. + const usingRetry = Boolean(options?.retry); + const retryOptions = options?.retry === true ? {} : options?.retry; + + const executeScan = () => scan>(params); + const getInitialResult = () => { + if (usingRetry) { + return executeWithRetry(executeScan, retryOptions); + } + return executeScan(); + }; + + let result = await getInitialResult(); + if (!result.items?.length && !result.lastEvaluatedKey) { return; } @@ -111,7 +130,15 @@ export const scanWithCallback = async ( } while (result.next) { - result = await result.next(); + const executeNext = () => result.next!(); + const getNextResult = () => { + if (usingRetry) { + return executeWithRetry(executeNext, retryOptions); + } + return executeNext(); + }; + + result = await getNextResult(); // If the result of the callback was `false`, that means the // user's intention was to stop further table scanning. diff --git a/packages/db-dynamodb/tsconfig.build.json b/packages/db-dynamodb/tsconfig.build.json index 92d1a331dd8..d14ec0b973f 100644 --- a/packages/db-dynamodb/tsconfig.build.json +++ b/packages/db-dynamodb/tsconfig.build.json @@ -7,7 +7,8 @@ { "path": "../db/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../handler-db/tsconfig.build.json" }, - { "path": "../plugins/tsconfig.build.json" } + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", diff --git a/packages/db-dynamodb/tsconfig.json b/packages/db-dynamodb/tsconfig.json index fe73ebd8a43..13fd84d1241 100644 --- a/packages/db-dynamodb/tsconfig.json +++ b/packages/db-dynamodb/tsconfig.json @@ -7,7 +7,8 @@ { "path": "../db" }, { "path": "../error" }, { "path": "../handler-db" }, - { "path": "../plugins" } + { "path": "../plugins" }, + { "path": "../utils" } ], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], @@ -27,7 +28,9 @@ "@webiny/handler-db/*": ["../handler-db/src/*"], "@webiny/handler-db": ["../handler-db/src"], "@webiny/plugins/*": ["../plugins/src/*"], - "@webiny/plugins": ["../plugins/src"] + "@webiny/plugins": ["../plugins/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"] }, "baseUrl": "." } diff --git a/packages/migrations/src/migrations/5.39.0/001/utils/getFallbackIdentity.ts b/packages/migrations/src/migrations/5.39.0/001/utils/getFallbackIdentity.ts index a06f016a298..f7ef6318ebe 100644 --- a/packages/migrations/src/migrations/5.39.0/001/utils/getFallbackIdentity.ts +++ b/packages/migrations/src/migrations/5.39.0/001/utils/getFallbackIdentity.ts @@ -1,6 +1,7 @@ import { CmsIdentity } from "@webiny/api-headless-cms/types"; import { queryAll } from "@webiny/db-dynamodb"; import { Entity } from "@webiny/db-dynamodb/toolbox"; +import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils"; const NON_EXISTING_DATA_MIGRATION_IDENTITY: CmsIdentity = { id: "data-migration", @@ -11,6 +12,7 @@ const NON_EXISTING_DATA_MIGRATION_IDENTITY: CmsIdentity = { interface GetFallbackIdentityParams { entity: Entity; tenant: string; + retryOptions?: ExecuteWithRetryOptions; } interface AdminUserRecord { @@ -25,19 +27,24 @@ const identitiesPerTenantCache: Record = {}; export const getFallbackIdentity = async ({ entity, - tenant + tenant, + retryOptions }: GetFallbackIdentityParams): Promise => { if (identitiesPerTenantCache[tenant]) { return identitiesPerTenantCache[tenant]; } - const allAdminUsersRecords = await queryAll({ - entity, - partitionKey: `T#${tenant}#ADMIN_USERS`, - options: { - index: "GSI1" - } - }); + const executeQueryAll = () => { + return queryAll({ + entity, + partitionKey: `T#${tenant}#ADMIN_USERS`, + options: { + index: "GSI1" + } + }); + }; + + const allAdminUsersRecords = await executeWithRetry(executeQueryAll, retryOptions); if (allAdminUsersRecords.length === 0) { // Hopefully it doesn't come to this, but we still need to consider it. diff --git a/packages/migrations/src/migrations/5.39.0/001/utils/getFirstLastPublishedOn.ts b/packages/migrations/src/migrations/5.39.0/001/utils/getFirstLastPublishedOn.ts index 6125aaf5221..bc7129cf3bf 100644 --- a/packages/migrations/src/migrations/5.39.0/001/utils/getFirstLastPublishedOn.ts +++ b/packages/migrations/src/migrations/5.39.0/001/utils/getFirstLastPublishedOn.ts @@ -1,5 +1,6 @@ import { createDdbEntryEntity } from "./../entities/createEntryEntity"; import { CmsEntry } from "../types"; +import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils"; const cachedEntryFirstLastPublishedOnBy: Record< string, @@ -13,6 +14,7 @@ interface CmsEntryWithPK extends CmsEntry { export interface getFirstLastPublishedOnParams { entry: CmsEntryWithPK; entryEntity: ReturnType; + retryOptions?: ExecuteWithRetryOptions; } export const getFirstLastPublishedOnBy = async (params: getFirstLastPublishedOnParams) => { @@ -29,11 +31,15 @@ export const getFirstLastPublishedOnBy = async (params: getFirstLastPublishedOnP lastPublishedBy: null }; - const result = await entryEntity.query(entry.PK, { - limit: 1, - eq: "P", - attributes: ["modifiedBy", "createdBy", "publishedOn"] - }); + const executeQuery = () => { + return entryEntity.query(entry.PK, { + limit: 1, + eq: "P", + attributes: ["modifiedBy", "createdBy", "publishedOn"] + }); + }; + + const result = await executeWithRetry(executeQuery, params.retryOptions); const publishedRecord = result.Items?.[0]; if (publishedRecord) { diff --git a/packages/migrations/src/migrations/5.39.0/001/utils/getOldestRevisionCreatedOn.ts b/packages/migrations/src/migrations/5.39.0/001/utils/getOldestRevisionCreatedOn.ts index 7214c2b1cbd..8a930406063 100644 --- a/packages/migrations/src/migrations/5.39.0/001/utils/getOldestRevisionCreatedOn.ts +++ b/packages/migrations/src/migrations/5.39.0/001/utils/getOldestRevisionCreatedOn.ts @@ -1,5 +1,6 @@ import { createDdbEntryEntity } from "./../entities/createEntryEntity"; import { CmsEntry } from "../types"; +import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils"; const cachedEntryCreatedOn: Record = {}; @@ -10,6 +11,7 @@ interface CmsEntryWithPK extends CmsEntry { export interface GetOldestRevisionCreatedOnParams { entry: CmsEntryWithPK; entryEntity: ReturnType; + retryOptions?: ExecuteWithRetryOptions; } export const getOldestRevisionCreatedOn = async (params: GetOldestRevisionCreatedOnParams) => { @@ -22,11 +24,15 @@ export const getOldestRevisionCreatedOn = async (params: GetOldestRevisionCreate if (entry.version === 1) { cachedEntryCreatedOn[entry.PK] = entry.createdOn; } else { - const result = await entryEntity.query(entry.PK, { - limit: 1, - beginsWith: "REV#", - attributes: ["createdOn"] - }); + const executeQuery = () => { + return entryEntity.query(entry.PK, { + limit: 1, + beginsWith: "REV#", + attributes: ["createdOn"] + }); + }; + + const result = await executeWithRetry(executeQuery, params.retryOptions); const oldestRevision = result.Items?.[0]; if (oldestRevision) { diff --git a/packages/migrations/src/migrations/5.39.6/001/ddb-es/worker.ts b/packages/migrations/src/migrations/5.39.6/001/ddb-es/worker.ts index 9418f20702c..408ef414b62 100644 --- a/packages/migrations/src/migrations/5.39.6/001/ddb-es/worker.ts +++ b/packages/migrations/src/migrations/5.39.6/001/ddb-es/worker.ts @@ -103,6 +103,11 @@ const createInitialStatus = (): MigrationStatus => { }; }; +let BATCH_WRITE_MAX_CHUNK = 20; +if (process.env.WEBINY_MIGRATION_5_39_6_001_BATCH_WRITE_MAX_CHUNK) { + BATCH_WRITE_MAX_CHUNK = parseInt(process.env.WEBINY_MIGRATION_5_39_6_001_BATCH_WRITE_MAX_CHUNK); +} + (async () => { const logger = createPinoLogger( { @@ -140,308 +145,452 @@ const createInitialStatus = (): MigrationStatus => { waitingTimeStep: argv.esHealthWaitingTimeStep }); - await ddbScanWithCallback( - { - entity: ddbEntryEntity, - options: { - segment: argv.segmentIndex, - segments: argv.totalSegments, - filters: [ - { - attr: "_et", - eq: "CmsEntries" - } - ], - startKey: status.lastEvaluatedKey || undefined, - limit: 100 - } - }, - async result => { - status.stats.iterationsCount++; - status.stats.recordsScanned += result.items.length; - - if (status.stats.iterationsCount % 5 === 0) { - // We log every 5th iteration. - logger.trace( - `[iteration #${status.stats.iterationsCount}] Reading ${result.items.length} record(s)...` - ); - } - - const ddbItemsToBatchWrite: BatchWriteItem[] = []; - const ddbEsItemsToBatchWrite: BatchWriteItem[] = []; - const ddbEsItemsToBatchRead: Record = {}; - - const fallbackDateTime = new Date().toISOString(); - - // Update records in primary DynamoDB table. Also do preparations for - // subsequent updates on DDB-ES DynamoDB table, and in Elasticsearch. - for (const item of result.items) { - const isFullyMigrated = - isMigratedEntry(item) && - hasValidTypeFieldValue(item) && - hasAllNonNullableValues(item); - - if (isFullyMigrated) { - status.stats.recordsSkipped++; - continue; - } - - // 1. Check if the data migration was ever performed. If not, let's perform it. - if (!isMigratedEntry(item)) { - // Get the oldest revision's `createdOn` value. We use that to set the entry-level `createdOn` value. - const createdOn = await getOldestRevisionCreatedOn({ - entry: item, - entryEntity: ddbEntryEntity - }); - - const firstLastPublishedOnByFields = await getFirstLastPublishedOnBy({ - entry: item, - entryEntity: ddbEntryEntity - }); - - assignNewMetaFields(item, { - createdOn, - ...firstLastPublishedOnByFields - }); - } - - // 2. We've noticed some of the records had an invalid `TYPE` field value - // in the database. This step addresses this issue. - if (!hasValidTypeFieldValue(item)) { - // Fixes the value of the `TYPE` field, if it's not valid. - fixTypeFieldValue(item); + try { + await ddbScanWithCallback( + { + entity: ddbEntryEntity, + options: { + segment: argv.segmentIndex, + segments: argv.totalSegments, + filters: [ + { + attr: "_et", + eq: "CmsEntries" + } + ], + startKey: status.lastEvaluatedKey || undefined, + limit: 100 } + }, + async result => { + status.stats.iterationsCount++; + status.stats.recordsScanned += result.items.length; - // 3. Finally, once both of the steps were performed, ensure that all - // new non-nullable meta fields have a value and nothing is missing. - if (!hasAllNonNullableValues(item)) { + if (status.stats.iterationsCount % 5 === 0) { + // We log every 5th iteration. logger.trace( - getNonNullableFieldsWithMissingValues(item), - `Detected an entry with missing values for non-nullable meta fields (${item.modelId}/${item.id}).` + `[iteration #${status.stats.iterationsCount}] Reading ${result.items.length} record(s)...` ); - - try { - const fallbackIdentity = await getFallbackIdentity({ - entity: ddbEntryEntity, - tenant: item.tenant - }); - - ensureAllNonNullableValues(item, { - dateTime: fallbackDateTime, - identity: fallbackIdentity - }); - - logger.trace( - `Successfully ensured all non-nullable meta fields have values (${item.modelId}/${item.id}). Will be saving into the database soon.` - ); - } catch (e) { - logger.debug( - `Failed to ensure all non-nullable meta fields have values (${item.modelId}/${item.id}): ${e.message}` - ); - } } - ddbItemsToBatchWrite.push(ddbEntryEntity.putBatch(item)); - - /** - * Prepare the loading of DynamoDB Elasticsearch part of the records. - */ - - const ddbEsLatestRecordKey = `${item.entryId}:L`; - if (ddbEsItemsToBatchRead[ddbEsLatestRecordKey]) { - continue; - } + const ddbItemsToBatchWrite: BatchWriteItem[] = []; + const ddbEsItemsToBatchWrite: BatchWriteItem[] = []; + const ddbEsItemsToBatchRead: Record = {}; - ddbEsItemsToBatchRead[ddbEsLatestRecordKey] = ddbEsEntryEntity.getBatch({ - PK: item.PK, - SK: "L" - }); + const fallbackDateTime = new Date().toISOString(); - const ddbEsPublishedRecordKey = `${item.entryId}:P`; - if (item.status === "published" || !!item.locked) { - ddbEsItemsToBatchRead[ddbEsPublishedRecordKey] = ddbEsEntryEntity.getBatch({ - PK: item.PK, - SK: "P" - }); - } - } + // Update records in primary DynamoDB table. Also do preparations for + // subsequent updates on DDB-ES DynamoDB table, and in Elasticsearch. + for (const item of result.items) { + const isFullyMigrated = + isMigratedEntry(item) && + hasValidTypeFieldValue(item) && + hasAllNonNullableValues(item); - if (Object.keys(ddbEsItemsToBatchRead).length > 0) { - /** - * Get all the records from DynamoDB Elasticsearch. - */ - const ddbEsRecords = await batchReadAll({ - table: ddbEsEntryEntity.table, - items: Object.values(ddbEsItemsToBatchRead) - }); - - for (const ddbEsRecord of ddbEsRecords) { - const decompressedData = await getDecompressedData(ddbEsRecord.data); - if (!decompressedData) { - logger.trace( - `[DDB-ES Table] Skipping record "${ddbEsRecord.PK}" as it is not a valid CMS entry...` - ); + if (isFullyMigrated) { + status.stats.recordsSkipped++; continue; } // 1. Check if the data migration was ever performed. If not, let's perform it. - if (!isMigratedEntry(decompressedData)) { + if (!isMigratedEntry(item)) { // Get the oldest revision's `createdOn` value. We use that to set the entry-level `createdOn` value. const createdOn = await getOldestRevisionCreatedOn({ - entry: { ...decompressedData, PK: ddbEsRecord.PK }, - entryEntity: ddbEntryEntity + entry: item, + entryEntity: ddbEntryEntity, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { error, item }, + `getOldestRevisionCreatedOn attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } }); const firstLastPublishedOnByFields = await getFirstLastPublishedOnBy({ - entry: { ...decompressedData, PK: ddbEsRecord.PK }, - entryEntity: ddbEntryEntity + entry: item, + entryEntity: ddbEntryEntity, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { error, item }, + `getFirstLastPublishedOnBy attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } }); - assignNewMetaFields(decompressedData, { + assignNewMetaFields(item, { createdOn, ...firstLastPublishedOnByFields }); } - // 2. Ensure new non-nullable meta fields have a value and nothing is missing. - if (!hasAllNonNullableValues(decompressedData)) { + // 2. We've noticed some of the records had an invalid `TYPE` field value + // in the database. This step addresses this issue. + if (!hasValidTypeFieldValue(item)) { + // Fixes the value of the `TYPE` field, if it's not valid. + fixTypeFieldValue(item); + } + + // 3. Finally, once both of the steps were performed, ensure that all + // new non-nullable meta fields have a value and nothing is missing. + if (!hasAllNonNullableValues(item)) { logger.trace( - getNonNullableFieldsWithMissingValues(decompressedData), - [ - `[DDB-ES Table] Detected an entry with missing values for non-nullable meta fields`, - `(${decompressedData.modelId}/${decompressedData.id}).` - ].join(" ") + getNonNullableFieldsWithMissingValues(item), + `Detected an entry with missing values for non-nullable meta fields (${item.modelId}/${item.id}).` ); try { const fallbackIdentity = await getFallbackIdentity({ entity: ddbEntryEntity, - tenant: decompressedData.tenant + tenant: item.tenant, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { error, item }, + `getFallbackIdentity attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } }); - ensureAllNonNullableValues(decompressedData, { + ensureAllNonNullableValues(item, { dateTime: fallbackDateTime, identity: fallbackIdentity }); logger.trace( - [ - `[DDB-ES Table] Successfully ensured all non-nullable meta fields`, - `have values (${decompressedData.modelId}/${decompressedData.id}).`, - "Will be saving the changes soon." - ].join(" ") + `Successfully ensured all non-nullable meta fields have values (${item.modelId}/${item.id}). Will be saving into the database soon.` ); } catch (e) { - logger.error( - [ - "[DDB-ES Table] Failed to ensure all non-nullable meta fields have values", - `(${decompressedData.modelId}/${decompressedData.id}): ${e.message}` - ].join(" ") + logger.debug( + `Failed to ensure all non-nullable meta fields have values (${item.modelId}/${item.id}): ${e.message}` ); } } - const compressedData = await getCompressedData(decompressedData); - - ddbEsItemsToBatchWrite.push( - ddbEsEntryEntity.putBatch({ - ...ddbEsRecord, - data: compressedData - }) - ); - } - } + ddbItemsToBatchWrite.push(ddbEntryEntity.putBatch(item)); - if (ddbItemsToBatchWrite.length) { - // Store data in primary DynamoDB table. - const execute = () => { - return batchWriteAll({ - table: ddbEntryEntity.table, - items: ddbItemsToBatchWrite - }); - }; + /** + * Prepare the loading of DynamoDB Elasticsearch part of the records. + */ - logger.trace( - `Storing ${ddbItemsToBatchWrite.length} record(s) in primary DynamoDB table...` - ); - await executeWithRetry(execute, { - onFailedAttempt: error => { - logger.warn( - `Batch write attempt #${error.attemptNumber} failed: ${error.message}` - ); + const ddbEsLatestRecordKey = `${item.entryId}:L`; + if (ddbEsItemsToBatchRead[ddbEsLatestRecordKey]) { + continue; } - }); - if (ddbEsItemsToBatchWrite.length) { - logger.trace( - `Storing ${ddbEsItemsToBatchWrite.length} record(s) in DDB-ES DynamoDB table...` - ); - const results = await waitUntilHealthy.wait({ - async onUnhealthy(params) { - const shouldWaitReason = params.waitingReason.name; - - logger.warn( - `Cluster is unhealthy (${shouldWaitReason}). Waiting for the cluster to become healthy...`, - params - ); - - if (status.stats.esHealthChecks.unhealthyReasons[shouldWaitReason]) { - status.stats.esHealthChecks.unhealthyReasons[shouldWaitReason]++; - } else { - status.stats.esHealthChecks.unhealthyReasons[shouldWaitReason] = 1; - } - } + ddbEsItemsToBatchRead[ddbEsLatestRecordKey] = ddbEsEntryEntity.getBatch({ + PK: item.PK, + SK: "L" }); - status.stats.esHealthChecks.checksCount++; - status.stats.esHealthChecks.timeSpentWaiting += results.runningTime; + const ddbEsPublishedRecordKey = `${item.entryId}:P`; + if (item.status === "published" || !!item.locked) { + ddbEsItemsToBatchRead[ddbEsPublishedRecordKey] = ddbEsEntryEntity.getBatch({ + PK: item.PK, + SK: "P" + }); + } + } - // Store data in DDB-ES DynamoDB table. - const executeDdbEs = () => { - return batchWriteAll({ + if (Object.keys(ddbEsItemsToBatchRead).length > 0) { + /** + * Get all the records from DynamoDB Elasticsearch. + */ + const executeBatchReadAll = () => { + return batchReadAll({ table: ddbEsEntryEntity.table, - items: ddbEsItemsToBatchWrite + items: Object.values(ddbEsItemsToBatchRead) }); }; - await executeWithRetry(executeDdbEs, { + const ddbEsRecords = await executeWithRetry(executeBatchReadAll, { onFailedAttempt: error => { logger.warn( - `[DDB-ES Table] Batch write attempt #${error.attemptNumber} failed: ${error.message}` + { error, items: Object.values(ddbEsItemsToBatchRead) }, + `[DDB-ES Table] Batch (ddbEsItemsToBatchRead) read attempt #${error.attemptNumber} failed: ${error.message}` ); } }); + + for (const ddbEsRecord of ddbEsRecords) { + const decompressedData = await getDecompressedData( + ddbEsRecord.data + ); + if (!decompressedData) { + logger.trace( + `[DDB-ES Table] Skipping record "${ddbEsRecord.PK}" as it is not a valid CMS entry...` + ); + continue; + } + + // 1. Check if the data migration was ever performed. If not, let's perform it. + if (!isMigratedEntry(decompressedData)) { + // Get the oldest revision's `createdOn` value. We use that to set the entry-level `createdOn` value. + const createdOn = await getOldestRevisionCreatedOn({ + entry: { ...decompressedData, PK: ddbEsRecord.PK }, + entryEntity: ddbEntryEntity, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { + error, + item: { ...decompressedData, PK: ddbEsRecord.PK } + }, + `[DDB-ES Table] getOldestRevisionCreatedOn attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } + }); + + const firstLastPublishedOnByFields = await getFirstLastPublishedOnBy({ + entry: { ...decompressedData, PK: ddbEsRecord.PK }, + entryEntity: ddbEntryEntity, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { + error, + item: { ...decompressedData, PK: ddbEsRecord.PK } + }, + `[DDB-ES Table] getFirstLastPublishedOnBy attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } + }); + + assignNewMetaFields(decompressedData, { + createdOn, + ...firstLastPublishedOnByFields + }); + } + + // 2. Ensure new non-nullable meta fields have a value and nothing is missing. + if (!hasAllNonNullableValues(decompressedData)) { + logger.trace( + getNonNullableFieldsWithMissingValues(decompressedData), + [ + `[DDB-ES Table] Detected an entry with missing values for non-nullable meta fields`, + `(${decompressedData.modelId}/${decompressedData.id}).` + ].join(" ") + ); + + try { + const fallbackIdentity = await getFallbackIdentity({ + entity: ddbEntryEntity, + tenant: decompressedData.tenant, + retryOptions: { + onFailedAttempt: error => { + logger.warn( + { error, item: ddbEntryEntity }, + `[DDB-ES Table] getFallbackIdentity attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } + }); + + ensureAllNonNullableValues(decompressedData, { + dateTime: fallbackDateTime, + identity: fallbackIdentity + }); + + logger.trace( + [ + `[DDB-ES Table] Successfully ensured all non-nullable meta fields`, + `have values (${decompressedData.modelId}/${decompressedData.id}).`, + "Will be saving the changes soon." + ].join(" ") + ); + } catch (e) { + logger.error( + [ + "[DDB-ES Table] Failed to ensure all non-nullable meta fields have values", + `(${decompressedData.modelId}/${decompressedData.id}): ${e.message}` + ].join(" ") + ); + } + } + + const compressedData = await getCompressedData(decompressedData); + + ddbEsItemsToBatchWrite.push( + ddbEsEntryEntity.putBatch({ + ...ddbEsRecord, + data: compressedData + }) + ); + } } - status.stats.recordsUpdated += ddbItemsToBatchWrite.length; - } + if (ddbItemsToBatchWrite.length) { + let ddbWriteError = false; + let ddbEsWriteError = false; + + // Store data in primary DynamoDB table. + const execute = () => { + return batchWriteAll( + { + table: ddbEntryEntity.table, + items: ddbItemsToBatchWrite + }, + BATCH_WRITE_MAX_CHUNK + ); + }; - // Update checkpoint after every batch. - let lastEvaluatedKey: LastEvaluatedKey = true; - if (result.lastEvaluatedKey) { - lastEvaluatedKey = result.lastEvaluatedKey as unknown as LastEvaluatedKeyObject; - } + logger.trace( + `Storing ${ddbItemsToBatchWrite.length} record(s) in primary DynamoDB table...` + ); + + try { + await executeWithRetry(execute, { + onFailedAttempt: error => { + logger.warn( + `Batch write attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + }); + } catch (e) { + ddbWriteError = true; + logger.error( + { + error: e, + ddbItemsToBatchWrite + }, + "After multiple retries, failed to batch-store records in primary DynamoDB table." + ); + } - status.lastEvaluatedKey = lastEvaluatedKey; + if (ddbEsItemsToBatchWrite.length) { + logger.trace( + `Storing ${ddbEsItemsToBatchWrite.length} record(s) in DDB-ES DynamoDB table...` + ); - if (lastEvaluatedKey === true) { - return false; - } + try { + const results = await waitUntilHealthy.wait({ + async onUnhealthy(params) { + const shouldWaitReason = params.waitingReason.name; + + logger.warn( + `Cluster is unhealthy (${shouldWaitReason}). Waiting for the cluster to become healthy...`, + params + ); + + if ( + status.stats.esHealthChecks.unhealthyReasons[ + shouldWaitReason + ] + ) { + status.stats.esHealthChecks.unhealthyReasons[ + shouldWaitReason + ]++; + } else { + status.stats.esHealthChecks.unhealthyReasons[ + shouldWaitReason + ] = 1; + } + } + }); - // Continue further scanning. - return true; - } - ); + status.stats.esHealthChecks.checksCount++; + status.stats.esHealthChecks.timeSpentWaiting += results.runningTime; + + // Store data in DDB-ES DynamoDB table. + const executeDdbEs = () => { + return batchWriteAll( + { + table: ddbEsEntryEntity.table, + items: ddbEsItemsToBatchWrite + }, + BATCH_WRITE_MAX_CHUNK + ); + }; + + await executeWithRetry(executeDdbEs, { + onFailedAttempt: error => { + logger.warn( + `[DDB-ES Table] Batch write attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + }); + } catch (e) { + ddbEsWriteError = true; + logger.error( + { + error: e, + ddbEsItemsToBatchWrite + }, + "After multiple retries, failed to batch-store records in DDB-ES DynamoDB table." + ); + } + } - // Store status in tmp file. - logger.trace({ status }, "Segment processing completed. Saving status to tmp file..."); - const logFilePath = path.join( - os.tmpdir(), - `webiny-5-39-6-meta-fields-data-migration-log-${argv.runId}-${argv.segmentIndex}.log` - ); + if (ddbEsWriteError || ddbWriteError) { + logger.warn( + 'Not increasing the "recordsUpdated" count due to write errors.' + ); + } else { + status.stats.recordsUpdated += ddbItemsToBatchWrite.length; + } + } + + // Update checkpoint after every batch. + let lastEvaluatedKey: LastEvaluatedKey = true; + if (result.lastEvaluatedKey) { + lastEvaluatedKey = result.lastEvaluatedKey as unknown as LastEvaluatedKeyObject; + } - // Save segment processing stats to a file. - fs.writeFileSync(logFilePath, JSON.stringify(status.stats, null, 2)); + status.lastEvaluatedKey = lastEvaluatedKey; - logger.trace(`Segment processing stats saved in ${logFilePath}.`); + if (lastEvaluatedKey === true) { + return false; + } + + // Continue further scanning. + return true; + }, + { + retry: { + onFailedAttempt: error => { + logger.warn( + { + lastEvaluatedKey: status.lastEvaluatedKey, + error + }, + `ddbScanWithCallback attempt #${error.attemptNumber} failed: ${error.message}` + ); + } + } + } + ); + + // Store status in tmp file. + logger.trace({ status }, "Segment processing completed. Saving status to tmp file..."); + const logFilePath = path.join( + os.tmpdir(), + `webiny-5-39-6-meta-fields-data-migration-log-${argv.runId}-${argv.segmentIndex}.log` + ); + + // Save segment processing stats to a file. + fs.writeFileSync(logFilePath, JSON.stringify(status.stats, null, 2)); + + logger.trace(`Segment processing stats saved in ${logFilePath}.`); + } catch (error) { + // Store status in tmp file. + logger.error( + { status, error }, + "Segment processing failed to complete. Saving status to tmp file..." + ); + const logFilePath = path.join( + os.tmpdir(), + `webiny-5-39-6-meta-fields-data-migration-log-${argv.runId}-${argv.segmentIndex}.log` + ); + + // Save segment processing stats to a file. + fs.writeFileSync(logFilePath, JSON.stringify(status.stats, null, 2)); + + logger.trace(`Segment processing stats saved in ${logFilePath}.`); + } })(); diff --git a/packages/pulumi-aws/src/apps/api/ApiOutput.ts b/packages/pulumi-aws/src/apps/api/ApiOutput.ts index b311f0d9fd7..885a0484671 100644 --- a/packages/pulumi-aws/src/apps/api/ApiOutput.ts +++ b/packages/pulumi-aws/src/apps/api/ApiOutput.ts @@ -19,6 +19,7 @@ export const ApiOutput = createAppModule({ return { apiDomain: output["apiDomain"] as string, apiUrl: output["apiUrl"] as string, + graphqlLambdaRole: output["graphqlLambdaRole"] as string, apwSchedulerEventRule: output["apwSchedulerEventRule"] as string | undefined, apwSchedulerEventTargetId: output["apwSchedulerEventTargetId"] as | string diff --git a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts index 60b59e4c8fb..3a2eeb974f4 100644 --- a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts @@ -265,6 +265,7 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = dynamoDbTable: core.primaryDynamodbTableName, migrationLambdaArn: migration.function.output.arn, graphqlLambdaName: graphql.functions.graphql.output.name, + graphqlLambdaRole: graphql.role.output.arn, backgroundTaskLambdaArn: backgroundTask.backgroundTask.output.arn, backgroundTaskStepFunctionArn: backgroundTask.stepFunction.output.arn, websocketApiId: websocket.websocketApi.output.id, diff --git a/packages/pulumi-aws/src/apps/createAppBucket.ts b/packages/pulumi-aws/src/apps/createAppBucket.ts index 324912f7a03..a074876d817 100644 --- a/packages/pulumi-aws/src/apps/createAppBucket.ts +++ b/packages/pulumi-aws/src/apps/createAppBucket.ts @@ -1,5 +1,6 @@ import * as aws from "@pulumi/aws"; import { PulumiApp } from "@webiny/pulumi"; +import { ApiOutput } from "~/apps/api"; export function createPublicAppBucket(app: PulumiApp, name: string) { const bucket = app.addResource(aws.s3.Bucket, { @@ -32,7 +33,10 @@ export function createPublicAppBucket(app: PulumiApp, name: string) { } // Forces S3 buckets to be available only through a cloudfront distribution. +// Requires `ApiOutput` module to be loaded. export function createPrivateAppBucket(app: PulumiApp, name: string) { + const api = app.getModule(ApiOutput); + const bucket = app.addResource(aws.s3.Bucket, { name: name, config: { @@ -88,6 +92,19 @@ export function createPrivateAppBucket(app: PulumiApp, name: string) { // and ListBucket allows to properly handle non-existing files (404) Action: ["s3:ListBucket", "s3:GetObject"], Resource: [`${arn}`, `${arn}/*`] + }, + { + Effect: "Allow", + Principal: { AWS: api.graphqlLambdaRole }, + Action: [ + "s3:GetObjectAcl", + "s3:DeleteObject", + "s3:PutObjectAcl", + "s3:PutObject", + "s3:GetObject", + "s3:ListBucket" + ], + Resource: [`${arn}`, `${arn}/*`] } ]; diff --git a/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts b/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts index 8fca4c62d98..5f236757a43 100644 --- a/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts @@ -1,12 +1,11 @@ import * as aws from "@pulumi/aws"; - +import * as pulumi from "@pulumi/pulumi"; import { createPulumiApp, PulumiAppParam, PulumiAppParamCallback } from "@webiny/pulumi"; import { addDomainsUrlsOutputs, tagResources } from "~/utils"; import { createPrivateAppBucket } from "../createAppBucket"; import { applyCustomDomain, CustomDomainParams } from "../customDomain"; -import * as pulumi from "@pulumi/pulumi"; -import { CoreOutput } from "../common/CoreOutput"; import { withServiceManifest } from "~/utils/withServiceManifest"; +import { ApiOutput, CoreOutput } from "~/apps"; export type ReactPulumiApp = ReturnType; @@ -64,6 +63,7 @@ export const createReactPulumiApp = (projectAppParams: CreateReactPulumiAppParam // Register core output as a module available for all other modules const core = app.addModule(CoreOutput); + app.addModule(ApiOutput); // Overrides must be applied via a handler, registered at the very start of the program. // By doing this, we're ensuring user's adjustments are not applied to late. diff --git a/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts b/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts index 644bd28ef71..cc49cd7d585 100644 --- a/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts +++ b/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts @@ -5,7 +5,7 @@ import { createPulumiApp, PulumiAppParamCallback, PulumiAppParam } from "@webiny import { createPrivateAppBucket } from "../createAppBucket"; import { applyCustomDomain, CustomDomainParams } from "../customDomain"; import { createPrerenderingService } from "./WebsitePrerendering"; -import { CoreOutput, VpcConfig } from "~/apps"; +import { CoreOutput, ApiOutput, VpcConfig } from "~/apps"; import { addDomainsUrlsOutputs, tagResources, withCommonLambdaEnvVariables } from "~/utils"; import { applyTenantRouter } from "~/apps/tenantRouter"; import { withServiceManifest } from "~/utils/withServiceManifest"; @@ -50,7 +50,7 @@ export interface CreateWebsitePulumiAppParams { } export const createWebsitePulumiApp = (projectAppParams: CreateWebsitePulumiAppParams = {}) => { - const app = createPulumiApp({ + const baseApp = createPulumiApp({ name: "website", path: "apps/website", config: projectAppParams, @@ -78,8 +78,9 @@ export const createWebsitePulumiApp = (projectAppParams: CreateWebsitePulumiAppP app.params.create.productionEnvironments || DEFAULT_PROD_ENV_NAMES; const isProduction = productionEnvironments.includes(app.params.run.env); - // Register core output as a module available for all other modules + // Register core and api output as a module, to be available to all other modules. const core = app.addModule(CoreOutput); + app.addModule(ApiOutput); // Register VPC config module to be available to other modules. const vpcEnabled = app.getParam(projectAppParams?.vpc) ?? isProduction; @@ -308,5 +309,42 @@ export const createWebsitePulumiApp = (projectAppParams: CreateWebsitePulumiAppP } }); - return withServiceManifest(withCommonLambdaEnvVariables(app)); + const app = withServiceManifest(withCommonLambdaEnvVariables(baseApp)); + + app.addHandler(() => { + const preview = baseApp.resources.preview; + const delivery = baseApp.resources.delivery; + + app.addServiceManifest({ + name: "website", + manifest: { + preview: { + cloudfront: { + distributionId: preview.cloudfront.output.id, + domainName: preview.cloudfront.output.domainName + }, + bucket: { + name: preview.bucket.output.id, + arn: preview.bucket.output.arn, + bucketDomainName: preview.bucket.output.bucketDomainName, + bucketRegionalDomainName: preview.bucket.output.bucketRegionalDomainName + } + }, + delivery: { + cloudfront: { + distributionId: delivery.cloudfront.output.id, + domainName: delivery.cloudfront.output.domainName + }, + bucket: { + name: delivery.bucket.output.id, + arn: delivery.bucket.output.arn, + bucketDomainName: delivery.bucket.output.bucketDomainName, + bucketRegionalDomainName: delivery.bucket.output.bucketRegionalDomainName + } + } + } + }); + }); + + return app; }; diff --git a/packages/ui/src/DelayedOnChange/DelayedOnChange.ts b/packages/ui/src/DelayedOnChange/DelayedOnChange.ts index 03656b476f1..636ebbf20d5 100644 --- a/packages/ui/src/DelayedOnChange/DelayedOnChange.ts +++ b/packages/ui/src/DelayedOnChange/DelayedOnChange.ts @@ -8,6 +8,7 @@ const emptyFunction = (): undefined => { export interface ApplyValueCb { (value: TValue): void; } + /** * This component is used to wrap Input and Textarea components to optimize form re-render. * These 2 are the only components that trigger form model change on each character input. @@ -145,7 +146,7 @@ export const DelayedOnChange = ({ if (ev.key === "Tab") { applyValue((ev.target as HTMLInputElement).value as any as TValue); realOnKeyDown(ev); - } else if (ev.key === "Enter" && props["data-on-enter"]) { + } else if (ev.key === "Enter") { applyValue((ev.target as HTMLInputElement).value as any as TValue); realOnKeyDown(ev); } else { diff --git a/packages/ui/src/Select/styled.tsx b/packages/ui/src/Select/styled.tsx index d24690790b8..787574e6a31 100644 --- a/packages/ui/src/Select/styled.tsx +++ b/packages/ui/src/Select/styled.tsx @@ -8,7 +8,7 @@ export const webinySelect = css` background-color: transparent; border-color: transparent; color: var(--webiny-theme-color-primary); - min-width: 200px; + width: 100%; .rmwc-select__native-control { opacity: 0; diff --git a/packages/utils/src/executeWithRetry.ts b/packages/utils/src/executeWithRetry.ts index 42eba2d08bc..d6bfa8283e5 100644 --- a/packages/utils/src/executeWithRetry.ts +++ b/packages/utils/src/executeWithRetry.ts @@ -1,8 +1,10 @@ import pRetry from "p-retry"; +export type ExecuteWithRetryOptions = Parameters[1]; + export const executeWithRetry = ( execute: () => Promise, - options?: Parameters[1] + options?: ExecuteWithRetryOptions ) => { const retries = 20; return pRetry(execute, { diff --git a/yarn.lock b/yarn.lock index c713212371a..5fb5d5b33be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14340,6 +14340,7 @@ __metadata: "@babel/preset-env": ^7.24.0 "@babel/runtime": ^7.24.0 "@types/jsonpack": ^1.1.0 + "@webiny/api": 0.0.0 "@webiny/api-headless-cms": 0.0.0 "@webiny/aws-sdk": 0.0.0 "@webiny/cli": 0.0.0 @@ -15423,6 +15424,7 @@ __metadata: "@webiny/form": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 + "@webiny/react-router": 0.0.0 "@webiny/ui": 0.0.0 "@webiny/validation": 0.0.0 apollo-client: ^2.6.10 @@ -17049,6 +17051,7 @@ __metadata: case: ^1.6.3 chalk: ^4.1.0 execa: ^5.0.0 + fast-glob: ^3.2.7 glob: ^7.1.2 load-json-file: ^6.2.0 lodash: ^4.17.21 @@ -17372,11 +17375,11 @@ __metadata: "@webiny/handler-db": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 + "@webiny/utils": 0.0.0 date-fns: ^2.22.1 dot-prop: ^6.0.1 dynamodb-toolbox: ^0.9.2 fuse.js: 7.0.0 - is-number: ^7.0.0 jest: ^29.7.0 jest-dynalite: ^3.2.0 lodash: ^4.17.21