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 ? (
-
- ) : (
- <>
-
-
- Sign In
-
-
-
-
- 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 ? (
+ <>
+
+
+ Sign In
+
+
+
+
+ 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 ? (
-
diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/styled.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/styled.tsx
index f5f4f1743a9..d9c1b9c5d6a 100644
--- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/styled.tsx
+++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/TagsList/styled.tsx
@@ -1,4 +1,6 @@
import styled from "@emotion/styled";
+import { Select } from "@webiny/ui/Select";
+import { Typography } from "@webiny/ui/Typography";
export const TagContainer = styled("div")`
display: flex;
@@ -36,13 +38,21 @@ export const EmptyContainer = styled("div")`
color: var(--webiny-theme-color-text-secondary);
fill: currentColor;
`;
+export const TagListWrapper = styled("div")`
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ margin: 8px;
+`;
-export const TagListWrapper = styled("div")({
- display: "flex",
- justifyContent: "space-between",
- marginBottom: 10,
- padding: "0 10px",
- ".tag-filter": {
- width: 110
- }
-});
+export const TagsFilterSelect = styled(Select)`
+ max-width: 150px;
+ min-width: auto;
+ flex-shrink: 0;
+`;
+
+export const TagsTitle = styled(Typography)`
+ margin: 0 0 5px;
+ flex: 1 1 auto;
+`;
diff --git a/packages/app-graphql-playground/src/plugins/Playground.tsx b/packages/app-graphql-playground/src/plugins/Playground.tsx
index b863541ffb9..725a968d98e 100644
--- a/packages/app-graphql-playground/src/plugins/Playground.tsx
+++ b/packages/app-graphql-playground/src/plugins/Playground.tsx
@@ -17,6 +17,7 @@ import { config as appConfig } from "@webiny/app/config";
import ApolloClient from "apollo-client";
import { GraphQLPlaygroundTabPlugin } from "~/types";
import { SecurityIdentity } from "@webiny/app-security/types";
+import { ORIGINAL_GQL_PLAYGROUND_URL, PATCHED_GQL_PLAYGROUND_URL } from "./constants";
const withHeaders = (link: ApolloLink, headers: Record): ApolloLink => {
return ApolloLink.from([
@@ -39,16 +40,20 @@ const initScripts = () => {
return resolve();
}
- return loadScript(
- "https://cdn.jsdelivr.net/npm/@apollographql/graphql-playground-react@1.7.32/build/static/js/middleware.js",
- resolve
- );
+ loadScript(PATCHED_GQL_PLAYGROUND_URL, (err: Error) => {
+ if (err) {
+ return loadScript(ORIGINAL_GQL_PLAYGROUND_URL, resolve);
+ }
+
+ resolve();
+ });
});
};
interface CreateApolloClientParams {
uri: string;
}
+
interface PlaygroundProps {
createApolloClient: (params: CreateApolloClientParams) => ApolloClient;
}
@@ -57,9 +62,11 @@ interface CreateApolloLinkCallableParams {
endpoint: string;
headers: Record;
}
+
interface CreateApolloLinkCallableResult {
link: ApolloLink;
}
+
interface CreateApolloLinkCallable {
(params: CreateApolloLinkCallableParams): CreateApolloLinkCallableResult;
}
diff --git a/packages/app-graphql-playground/src/plugins/constants.ts b/packages/app-graphql-playground/src/plugins/constants.ts
new file mode 100644
index 00000000000..9a3a5508b92
--- /dev/null
+++ b/packages/app-graphql-playground/src/plugins/constants.ts
@@ -0,0 +1,5 @@
+export const ORIGINAL_GQL_PLAYGROUND_URL =
+ "https://cdn.jsdelivr.net/npm/@apollographql/graphql-playground-react@1.7.32/build/static/js/middleware.js";
+
+export const PATCHED_GQL_PLAYGROUND_URL =
+ "https://webiny-public.s3.us-east-2.amazonaws.com/project-scripts/gql-playground-mw-1.7.42-patched.js";
diff --git a/packages/app-headless-cms-common/src/constants.ts b/packages/app-headless-cms-common/src/constants.ts
new file mode 100644
index 00000000000..f764a149c27
--- /dev/null
+++ b/packages/app-headless-cms-common/src/constants.ts
@@ -0,0 +1 @@
+export const CMS_MODEL_SINGLETON_TAG = "singleton";
diff --git a/packages/app-headless-cms-common/src/entries.graphql.ts b/packages/app-headless-cms-common/src/entries.graphql.ts
index bfd960a4174..8512cb63058 100644
--- a/packages/app-headless-cms-common/src/entries.graphql.ts
+++ b/packages/app-headless-cms-common/src/entries.graphql.ts
@@ -5,133 +5,146 @@ import {
CmsEditorContentModel,
CmsErrorResponse,
CmsMetaResponse,
- CmsModelField
+ CmsModelField,
+ CmsModel
} from "~/types";
import { createFieldsList } from "./createFieldsList";
import { getModelTitleFieldId } from "./getModelTitleFieldId";
import { FormValidationOptions } from "@webiny/form";
+import { CMS_MODEL_SINGLETON_TAG } from "./constants";
const CONTENT_META_FIELDS = /* GraphQL */ `
- meta {
- title
- description
- image
- version
- locked
- status
- }
+ title
+ description
+ image
+ version
+ locked
+ status
`;
-const CONTENT_ENTRY_SYSTEM_FIELDS = /* GraphQL */ `
- id
- entryId
- createdOn
- savedOn
- modifiedOn,
- deletedOn
- firstPublishedOn
- lastPublishedOn
- createdBy {
- id
- type
- displayName
- }
- savedBy {
- id
- type
- displayName
- }
- modifiedBy {
- id
- type
- displayName
- }
- deletedBy {
- id
- type
- displayName
- }
- firstPublishedBy {
- id
- type
- displayName
- }
- lastPublishedBy {
- id
- type
- displayName
- }
- revisionCreatedOn
- revisionSavedOn
- revisionModifiedOn
- revisionDeletedOn
- revisionFirstPublishedOn
- revisionLastPublishedOn
- revisionCreatedBy {
- id
- type
- displayName
- }
- revisionSavedBy {
- id
- type
- displayName
- }
- revisionModifiedBy {
- id
- type
- displayName
- }
- revisionDeletedBy {
- id
- type
- displayName
- }
- revisionFirstPublishedBy {
- id
- type
- displayName
- }
- revisionLastPublishedBy {
- id
- type
- displayName
- }
- revisionCreatedOn
- revisionSavedOn
- revisionModifiedOn
- revisionFirstPublishedOn
- revisionLastPublishedOn
- revisionCreatedBy {
- id
- type
- displayName
- }
- revisionSavedBy {
- id
- type
- displayName
- }
- revisionModifiedBy {
- id
- type
- displayName
- }
- revisionFirstPublishedBy {
- id
- type
- displayName
+const createEntrySystemFields = (model: CmsModel) => {
+ const isSingletonModel = model.tags.includes(CMS_MODEL_SINGLETON_TAG);
+
+ let optionalFields = "";
+ if (!isSingletonModel) {
+ optionalFields = `
+ wbyAco_location {
+ folderId
+ }
+ meta {
+ ${CONTENT_META_FIELDS}
+ }
+ `;
}
- revisionLastPublishedBy {
+
+ return /* GraphQL */ `
id
- type
- displayName
- }
- wbyAco_location {
- folderId
- }
- ${CONTENT_META_FIELDS}
-`;
+ entryId
+ createdOn
+ savedOn
+ modifiedOn,
+ deletedOn
+ firstPublishedOn
+ lastPublishedOn
+ createdBy {
+ id
+ type
+ displayName
+ }
+ savedBy {
+ id
+ type
+ displayName
+ }
+ modifiedBy {
+ id
+ type
+ displayName
+ }
+ deletedBy {
+ id
+ type
+ displayName
+ }
+ firstPublishedBy {
+ id
+ type
+ displayName
+ }
+ lastPublishedBy {
+ id
+ type
+ displayName
+ }
+ revisionCreatedOn
+ revisionSavedOn
+ revisionModifiedOn
+ revisionDeletedOn
+ revisionFirstPublishedOn
+ revisionLastPublishedOn
+ revisionCreatedBy {
+ id
+ type
+ displayName
+ }
+ revisionSavedBy {
+ id
+ type
+ displayName
+ }
+ revisionModifiedBy {
+ id
+ type
+ displayName
+ }
+ revisionDeletedBy {
+ id
+ type
+ displayName
+ }
+ revisionFirstPublishedBy {
+ id
+ type
+ displayName
+ }
+ revisionLastPublishedBy {
+ id
+ type
+ displayName
+ }
+ revisionCreatedOn
+ revisionSavedOn
+ revisionModifiedOn
+ revisionFirstPublishedOn
+ revisionLastPublishedOn
+ revisionCreatedBy {
+ id
+ type
+ displayName
+ }
+ revisionSavedBy {
+ id
+ type
+ displayName
+ }
+ revisionModifiedBy {
+ id
+ type
+ displayName
+ }
+ revisionFirstPublishedBy {
+ id
+ type
+ displayName
+ }
+ revisionLastPublishedBy {
+ id
+ type
+ displayName
+ }
+ ${optionalFields}
+ `;
+};
const ERROR_FIELD = /* GraphQL */ `
{
@@ -165,7 +178,32 @@ export const createReadQuery = (model: CmsEditorContentModel) => {
query CmsEntriesGet${model.singularApiName}($revision: ID, $entryId: ID) {
content: get${model.singularApiName}(revision: $revision, entryId: $entryId) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
+ ${createFieldsList({ model, fields: model.fields })}
+ }
+ error ${ERROR_FIELD}
+ }
+ }
+ `;
+};
+
+/**
+ * ############################################
+ * Get CMS Singleton Entry Query
+ */
+export interface CmsEntryGetSingletonQueryResponse {
+ content: {
+ data: CmsContentEntry;
+ error: CmsErrorResponse | null;
+ };
+}
+
+export const createReadSingletonQuery = (model: CmsEditorContentModel) => {
+ return gql`
+ query CmsEntryGetSingleton${model.singularApiName} {
+ content: get${model.singularApiName} {
+ data {
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
@@ -195,7 +233,7 @@ export const createRevisionsQuery = (model: CmsEditorContentModel) => {
query CmsEntriesGet${model.singularApiName}Revisions($id: ID!) {
revisions: get${model.singularApiName}Revisions(id: $id) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
}
error ${ERROR_FIELD}
}
@@ -230,7 +268,7 @@ export const createListQueryDataSelection = (
fields?: CmsModelField[]
) => {
return `
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${fields ? createFieldsList({ model, fields }) : ""}
${!fields ? getModelTitleFieldId(model) : ""}
`;
@@ -315,7 +353,7 @@ export const createRestoreFromBinMutation = (model: CmsEditorContentModel) => {
mutation CmsEntriesRestore${model.singularApiName}FromBin($revision: ID!) {
content: restore${model.singularApiName}FromBin(revision: $revision) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
@@ -347,10 +385,12 @@ export const createCreateMutation = (model: CmsEditorContentModel) => {
const createFields = createFieldsList({ model, fields: model.fields });
return gql`
- mutation CmsEntriesCreate${model.singularApiName}($data: ${model.singularApiName}Input!, $options: CreateCmsEntryOptionsInput) {
+ mutation CmsEntriesCreate${model.singularApiName}($data: ${
+ model.singularApiName
+ }Input!, $options: CreateCmsEntryOptionsInput) {
content: create${model.singularApiName}(data: $data, options: $options) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFields}
}
error ${ERROR_FIELD}
@@ -388,7 +428,7 @@ export const createCreateFromMutation = (model: CmsEditorContentModel) => {
model.singularApiName
}From(revision: $revision, data: $data, options: $options) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
@@ -425,7 +465,7 @@ export const createUpdateMutation = (model: CmsEditorContentModel) => {
model.singularApiName
}(revision: $revision, data: $data, options: $options) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
@@ -434,6 +474,41 @@ export const createUpdateMutation = (model: CmsEditorContentModel) => {
`;
};
+/**
+ * ############################################
+ * Update Singleton Mutation
+ */
+export interface CmsEntryUpdateSingletonMutationResponse {
+ content: {
+ data?: CmsContentEntry;
+ error?: CmsErrorResponse;
+ };
+}
+
+export interface CmsEntryUpdateSingletonMutationVariables {
+ /**
+ * We have any here because we do not know which fields does entry have
+ */
+ data: Record;
+ options?: FormValidationOptions;
+}
+
+export const createUpdateSingletonMutation = (model: CmsEditorContentModel) => {
+ return gql`
+ mutation CmsUpdate${model.singularApiName}($data: ${
+ model.singularApiName
+ }Input!, $options: UpdateCmsEntryOptionsInput) {
+ content: update${model.singularApiName}(data: $data, options: $options) {
+ data {
+ ${createEntrySystemFields(model)}
+ ${createFieldsList({ model, fields: model.fields })}
+ }
+ error ${ERROR_FIELD}
+ }
+ }
+ `;
+};
+
/**
* ############################################
* Publish Mutation
@@ -454,7 +529,7 @@ export const createPublishMutation = (model: CmsEditorContentModel) => {
mutation CmsPublish${model.singularApiName}($revision: ID!) {
content: publish${model.singularApiName}(revision: $revision) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
@@ -482,7 +557,7 @@ export const createUnpublishMutation = (model: CmsEditorContentModel) => {
mutation CmsUnpublish${model.singularApiName}($revision: ID!) {
content: unpublish${model.singularApiName}(revision: $revision) {
data {
- ${CONTENT_ENTRY_SYSTEM_FIELDS}
+ ${createEntrySystemFields(model)}
${createFieldsList({ model, fields: model.fields })}
}
error ${ERROR_FIELD}
diff --git a/packages/app-headless-cms-common/src/index.ts b/packages/app-headless-cms-common/src/index.ts
index d2e39ac101e..3020716d964 100644
--- a/packages/app-headless-cms-common/src/index.ts
+++ b/packages/app-headless-cms-common/src/index.ts
@@ -2,3 +2,4 @@ export * from "./entries.graphql";
export * from "./getModelTitleFieldId";
export * from "./createFieldsList";
export * from "./prepareFormData";
+export * from "./constants";
diff --git a/packages/app-headless-cms-common/src/types/index.ts b/packages/app-headless-cms-common/src/types/index.ts
index a60b07e5f78..0adb6719448 100644
--- a/packages/app-headless-cms-common/src/types/index.ts
+++ b/packages/app-headless-cms-common/src/types/index.ts
@@ -406,16 +406,16 @@ export interface CmsContentEntryRevision {
deletedBy: CmsIdentity | null;
revisionCreatedOn: string;
revisionSavedOn: string;
- revisionModifiedOn: string;
+ revisionModifiedOn: string | null;
revisionDeletedOn: string | null;
- revisionFirstPublishedOn: string;
- revisionLastPublishedOn: string;
+ revisionFirstPublishedOn: string | null;
+ revisionLastPublishedOn: string | null;
revisionCreatedBy: CmsIdentity;
revisionSavedBy: CmsIdentity;
- revisionModifiedBy: CmsIdentity;
+ revisionModifiedBy: CmsIdentity | null;
revisionDeletedBy: CmsIdentity | null;
- revisionFirstPublishedBy: CmsIdentity;
- revisionLastPublishedBy: CmsIdentity;
+ revisionFirstPublishedBy: CmsIdentity | null;
+ revisionLastPublishedBy: CmsIdentity | null;
wbyAco_location: Location;
meta: {
title: string;
diff --git a/packages/app-headless-cms/src/HeadlessCMS.tsx b/packages/app-headless-cms/src/HeadlessCMS.tsx
index 7df7e45cc0c..3123ab9b252 100644
--- a/packages/app-headless-cms/src/HeadlessCMS.tsx
+++ b/packages/app-headless-cms/src/HeadlessCMS.tsx
@@ -9,6 +9,7 @@ import apiInformation from "~/admin/plugins/apiInformation";
import { ContentEntriesModule } from "~/admin/views/contentEntries/ContentEntriesModule";
import allPlugins from "~/allPlugins";
import { LexicalEditorCmsPlugin } from "~/admin/components/LexicalCmsEditor/LexicalEditorCmsPlugin";
+import { SingletonContentEntryModule } from "~/admin/views/contentEntries/SingletonContentEntryModule";
interface HeadlessCMSProvider {
children: React.ReactNode;
@@ -63,6 +64,7 @@ const HeadlessCMSExtension = ({ createApolloClient }: HeadlessCMSProps) => {
return (
+
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx
index a911e28068c..13f5aa2a7d7 100644
--- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx
@@ -3,9 +3,12 @@ import styled from "@emotion/styled";
import { CmsContentEntry } from "~/types";
import { makeDecoratable } from "@webiny/app-admin";
import { ModelProvider, useModel } from "~/admin/components/ModelProvider";
-import { Header } from "~/admin/components/ContentEntryForm/Header";
import { useFormRenderer } from "~/admin/components/ContentEntryForm/useFormRenderer";
-import { ContentEntryFormContext, ContentEntryFormProvider } from "./ContentEntryFormProvider";
+import {
+ ContentEntryFormContext,
+ ContentEntryFormProvider,
+ PersistEntry
+} from "./ContentEntryFormProvider";
import { CustomLayout } from "./CustomLayout";
import { DefaultLayout } from "./DefaultLayout";
import { useGoToRevision } from "~/admin/components/ContentEntryForm/useGoToRevision";
@@ -22,30 +25,25 @@ export interface ContentEntryFormProps {
* @param entry
*/
onAfterCreate?: (entry: CmsContentEntry) => void;
- header?: boolean;
+ /**
+ * This callback is executed when the form is valid, and it needs to persist the content entry.
+ */
+ persistEntry: PersistEntry;
+ header?: React.ReactNode;
/**
* This prop is used to get a reference to `saveEntry` callback, so it can be triggered by components
* outside the ContentEntryForm context.
* TODO: introduce a `layout` prop to be able to mount arbitrary components around the entry form, within the context.
*/
setSaveEntry?: (cb: ContentEntryFormContext["saveEntry"]) => void;
- /**
- * This flag exists for a lack of better Apollo cache control, at the moment.
- * We use this flag when we need to tell the system to add new entries to apollo cache.
- * Why would you want to NOT add entries to cache? When using a `ref` field, which usually points to
- * a different model than the main entry you're working on. Example: Book -> Author, you don't want
- * an Author created via a `ref` field dialog to be added to the list of Books.
- * TODO: revisit this, and look for a better solution.
- */
- addEntryToListCache?: boolean;
}
export const ContentEntryForm = makeDecoratable(
"ContentEntryForm",
({
entry,
+ persistEntry,
onAfterCreate,
- addEntryToListCache,
setSaveEntry,
header = true
}: ContentEntryFormProps) => {
@@ -75,11 +73,11 @@ export const ContentEntryForm = makeDecoratable(
entry={entry}
onAfterCreate={onAfterCreate || defaultOnAfterCreate}
setSaveEntry={setSaveEntry}
- addItemToListCache={addEntryToListCache}
confirmNavigationIfDirty={true}
+ persistEntry={persistEntry}
>
- {header ? : null}
+ {header ? header : null}
{formRenderer ? (
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx
index c89d3f995cb..35c3bf13a8e 100644
--- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx
@@ -1,7 +1,7 @@
import React from "react";
import styled from "@emotion/styled";
import { makeDecoratable } from "@webiny/app-admin";
-import { CmsEditorContentModel } from "~/types";
+import { CmsContentEntry, CmsEditorContentModel } from "~/types";
import { ModelProvider } from "~/admin/components/ModelProvider";
import { useFormRenderer } from "~/admin/components/ContentEntryForm/useFormRenderer";
import { CustomLayout } from "~/admin/components/ContentEntryForm/CustomLayout";
@@ -28,6 +28,7 @@ export const ContentEntryFormPreview = makeDecoratable(
Promise.resolve({ entry } as { entry: CmsContentEntry })}
confirmNavigationIfDirty={false}
>
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormProvider.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormProvider.tsx
index 6641be25add..a388521bc7f 100644
--- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormProvider.tsx
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormProvider.tsx
@@ -5,14 +5,14 @@ import { Form, FormAPI, FormOnSubmit, FormValidation } from "@webiny/form";
import { CmsContentEntry, CmsModel } from "@webiny/app-headless-cms-common/types";
import { CompositionScope, useSnackbar } from "@webiny/app-admin";
import { prepareFormData } from "@webiny/app-headless-cms-common";
-import { useContentEntry } from "~/index";
-import { PartialCmsContentEntryWithId } from "~/admin/contexts/Cms";
+import { CreateEntryResponse, UpdateEntryRevisionResponse } from "~/admin/contexts/Cms";
const promptMessage =
"There are some unsaved changes! Are you sure you want to navigate away and discard all changes?";
interface SaveEntryOptions {
skipValidators?: string[];
+ createNewRevision?: boolean;
}
export interface ContentEntryFormContext {
@@ -30,22 +30,23 @@ interface InvalidFieldError {
error: string;
}
-interface PersistEntryParams {
- entry: PartialCmsContentEntryWithId;
- isLocked: boolean;
-}
-
export interface SetSaveEntry {
(cb: ContentEntryFormContext["saveEntry"]): void;
}
+export interface PersistEntry {
+ (entry: Partial, options?: SaveEntryOptions): Promise<
+ CreateEntryResponse | UpdateEntryRevisionResponse
+ >;
+}
+
interface ContentEntryFormProviderProps {
entry: Partial;
model: CmsModel;
+ persistEntry: PersistEntry;
confirmNavigationIfDirty: boolean;
onAfterCreate?: (entry: CmsContentEntry) => void;
setSaveEntry?: SetSaveEntry;
- addItemToListCache?: boolean;
children: React.ReactNode;
}
@@ -60,15 +61,14 @@ export const ContentEntryFormProvider = ({
model,
entry,
children,
+ persistEntry,
onAfterCreate,
setSaveEntry,
- addItemToListCache,
confirmNavigationIfDirty
}: ContentEntryFormProviderProps) => {
const ref = useRef | null>(null);
const [invalidFields, setInvalidFields] = useState({});
const { showSnackbar } = useSnackbar();
- const contentEntry = useContentEntry();
const saveOptionsRef = useRef({ skipValidators: undefined });
const saveEntry = useCallback(async (options: SaveEntryOptions = {}) => {
@@ -79,32 +79,6 @@ export const ContentEntryFormProvider = ({
}) as unknown as Promise;
}, []);
- const persistEntry = ({ entry, isLocked }: PersistEntryParams) => {
- const options = {
- skipValidators: saveOptionsRef.current.skipValidators,
- addItemToListCache
- };
-
- if (!entry.id) {
- return contentEntry.createEntry({ entry, options });
- }
-
- if (!isLocked) {
- return contentEntry.updateEntryRevision({
- entry,
- options: { skipValidators: options?.skipValidators }
- });
- }
-
- const { id, ...input } = entry;
-
- return contentEntry.createEntryRevisionFrom({
- id,
- input,
- options: { skipValidators: options?.skipValidators }
- });
- };
-
const onFormSubmit: FormOnSubmit = async data => {
const fieldsIds = model.fields.map(item => item.fieldId);
const formData = pick(data, [...fieldsIds]);
@@ -112,10 +86,13 @@ export const ContentEntryFormProvider = ({
const gqlData = prepareFormData(formData, model.fields) as Partial;
const isNewEntry = data.id === undefined;
- const { entry, error } = await persistEntry({
- entry: { id: data.id, ...gqlData },
- isLocked: data.meta?.locked === true
- });
+ const { entry, error } = await persistEntry(
+ { id: data.id, ...gqlData },
+ {
+ skipValidators: saveOptionsRef.current.skipValidators,
+ createNewRevision: data.meta?.locked
+ }
+ );
if (error) {
showSnackbar(error.message);
@@ -123,6 +100,7 @@ export const ContentEntryFormProvider = ({
return;
}
+ showSnackbar("Entry saved successfully!");
setInvalidFields({});
const isNewRevision = !isNewEntry && data.id !== entry.id;
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx
index f9841f10407..bec2fac90f0 100644
--- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/DeleteEntry/DeleteEntry.tsx
@@ -27,7 +27,7 @@ export const DeleteEntry = () => {
return (
}
- label={"Trash"}
+ label={"Trash entry"}
onAction={deleteEntry}
disabled={!entry.id || loading}
data-testid={"cms.content-form.header.delete"}
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SaveAction.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SaveAction.tsx
new file mode 100644
index 00000000000..d8d230d5edc
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SaveAction.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+import { ContentEntryEditorConfig } from "~/ContentEntryEditorConfig";
+import { useContentEntryForm } from "~/admin/components/ContentEntryForm/useContentEntryForm";
+
+const { Actions } = ContentEntryEditorConfig;
+
+export const SaveAction = () => {
+ const { useButtons } = Actions.ButtonAction;
+ const { ButtonPrimary } = useButtons();
+ const { saveEntry } = useContentEntryForm();
+
+ return (
+
+ Save
+
+ );
+};
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SingletonHeader.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SingletonHeader.tsx
new file mode 100644
index 00000000000..ecb97bdb08d
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/SingletonHeader.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { Buttons } from "@webiny/app-admin";
+import styled from "@emotion/styled";
+import { SaveAction } from "./SaveAction";
+
+const ToolbarGrid = styled.div`
+ padding: 15px;
+ border-bottom: 1px solid var(--mdc-theme-on-background);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const Actions = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const ModelName = styled.div`
+ font-family: var(--mdc-typography-font-family);
+ padding: 10px 0;
+ font-size: 24px;
+`;
+
+export interface SingletonHeaderProps {
+ title: string;
+}
+
+export const SingletonHeader = ({ title }: SingletonHeaderProps) => {
+ return (
+
+ {title}
+
+ }]} />
+
+
+ );
+};
diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/index.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/index.tsx
new file mode 100644
index 00000000000..64d3a6148fc
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/SingletonHeader/index.tsx
@@ -0,0 +1 @@
+export * from "./SingletonHeader";
diff --git a/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision.tsx b/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision.tsx
new file mode 100644
index 00000000000..e931764dc57
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision.tsx
@@ -0,0 +1,89 @@
+import React, { useEffect } from "react";
+import styled from "@emotion/styled";
+import { useBind } from "@webiny/form";
+import { useDialogs, useSnackbar } from "@webiny/app-admin";
+import { useContentEntry } from "~/admin/views/contentEntries/hooks";
+import { CircularProgress } from "@webiny/ui/Progress";
+import { EntryRevisionDeletedSnackbarMessage } from "./ShowConfirmationOnDeleteRevision/EntryRevisionDeletedSnackbarMessage";
+import {
+ DeleteEntryRevisionParams,
+ DeleteEntryRevisionResponse
+} from "~/admin/views/contentEntries/ContentEntry/ContentEntryContext";
+
+type GetEntry = ReturnType["getEntry"];
+
+const Title = styled.span`
+ font-weight: bold;
+`;
+
+const EntryMessage = ({ id, getEntryRevision }: { id: string; getEntryRevision: GetEntry }) => {
+ const entryRevisionBind = useBind({
+ name: "entryRevision"
+ });
+
+ useEffect(() => {
+ getEntryRevision({ id }).then(response => {
+ entryRevisionBind.onChange(response.entry);
+ });
+ }, []);
+
+ if (!entryRevisionBind.value) {
+ return ;
+ }
+
+ return (
+
+ Are you sure you want to permanently delete revision
+
#{entryRevisionBind.value.meta.version} of the
+ {entryRevisionBind.value.meta.title} entry?
+
+ );
+};
+
+export const ShowConfirmationOnDeleteRevision = useContentEntry.createDecorator(baseHook => {
+ return () => {
+ const hook = baseHook();
+ const dialogs = useDialogs();
+ const { showSnackbar } = useSnackbar();
+
+ const onAccept = async (params: DeleteEntryRevisionParams) => {
+ const revisionToDelete = hook.revisions.find(rev => rev.id === params.id)!;
+
+ const response = await hook.deleteEntryRevision(revisionToDelete);
+ if (typeof response === "object" && response.error) {
+ const { error } = response;
+ showSnackbar(error.message);
+ return response;
+ }
+
+ showSnackbar(
+
+ );
+
+ return response;
+ };
+ const showConfirmation = (params: DeleteEntryRevisionParams) => {
+ return new Promise(resolve => {
+ dialogs.showDialog({
+ title: "Delete revision",
+ content: ,
+ acceptLabel: "Confirm",
+ cancelLabel: "Cancel",
+ loadingLabel: "Deleting revision...",
+ onAccept: async () => resolve(await onAccept(params)),
+ onClose: () => resolve({ error: { message: "Cancelled" } })
+ });
+ });
+ };
+
+ return {
+ ...hook,
+ deleteEntryRevision: params => {
+ return showConfirmation(params);
+ }
+ };
+ };
+});
diff --git a/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision/EntryRevisionDeletedSnackbarMessage.tsx b/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision/EntryRevisionDeletedSnackbarMessage.tsx
new file mode 100644
index 00000000000..24d16a07486
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/components/Decorators/ShowConfirmationOnDeleteRevision/EntryRevisionDeletedSnackbarMessage.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { CmsContentEntryRevision } from "@webiny/app-headless-cms-common/types";
+
+interface RevisionDeletedSnackbarMessageProps {
+ deletedRevision: CmsContentEntryRevision;
+ newLatestRevision?: CmsContentEntryRevision;
+}
+
+export const EntryRevisionDeletedSnackbarMessage = ({
+ deletedRevision,
+ newLatestRevision
+}: RevisionDeletedSnackbarMessageProps) => {
+ if (newLatestRevision) {
+ return (
+
+ Successfully deleted revision #{deletedRevision.meta.version}.
+ Redirecting to revision #{newLatestRevision.meta.version}...
+
+ );
+ }
+
+ return (
+
+ Successfully deleted last revision #{deletedRevision.meta.version}.
+ Redirecting to list of entries...
+
+ );
+};
diff --git a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx
index 46cc3df2c8e..a68eb682edb 100644
--- a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx
+++ b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx
@@ -3,7 +3,7 @@ import ApolloClient from "apollo-client";
import { useI18N } from "@webiny/app-i18n/hooks/useI18N";
import { CircularProgress } from "@webiny/ui/Progress";
import { config as appConfig } from "@webiny/app/config";
-import { CmsContentEntry, CmsErrorResponse, CmsModel } from "~/types";
+import { CmsContentEntry, CmsContentEntryRevision, CmsErrorResponse, CmsModel } from "~/types";
import {
CmsEntryPublishMutationResponse,
CmsEntryPublishMutationVariables,
@@ -26,35 +26,49 @@ import {
CmsEntryCreateFromMutationVariables,
CmsEntryGetQueryResponse,
CmsEntryGetQueryVariables,
+ createReadSingletonQuery,
+ CmsEntryGetSingletonQueryResponse,
+ createUpdateSingletonMutation,
+ CmsEntryUpdateSingletonMutationResponse,
+ CmsEntryUpdateSingletonMutationVariables,
+ createRevisionsQuery,
+ CmsEntriesListRevisionsQueryResponse,
+ CmsEntriesListRevisionsQueryVariables,
createBulkActionMutation,
CmsEntryBulkActionMutationResponse,
CmsEntryBulkActionMutationVariables
} from "@webiny/app-headless-cms-common";
import { getFetchPolicy } from "~/utils/getFetchPolicy";
-interface EntryError {
+export interface EntryError {
message: string;
code?: string;
data?: Record;
}
-interface OperationSuccess {
+export interface OperationSuccess {
entry: CmsContentEntry;
error?: never;
}
-interface BulkActionOperationSuccess {
+export interface OperationError {
+ entry?: never;
+ error: EntryError;
+}
+
+export interface BulkActionOperationSuccess {
id: string;
error?: never;
}
-interface OperationError {
- entry?: never;
- error: EntryError;
+interface ListEntryRevisionsOperationSuccess {
+ revisions: CmsContentEntryRevision[];
+ error?: never;
}
export type PartialCmsContentEntryWithId = Partial & { id: string };
export type GetEntryResponse = OperationSuccess | OperationError;
+export type ListEntryRevisionsResponse = ListEntryRevisionsOperationSuccess | OperationError;
export type CreateEntryResponse = OperationSuccess | OperationError;
export type CreateEntryRevisionFromResponse = OperationSuccess | OperationError;
export type UpdateEntryRevisionResponse = OperationSuccess | OperationError;
@@ -65,7 +79,7 @@ export type BulkActionResponse = BulkActionOperationSuccess | OperationError;
export interface CreateEntryParams {
model: CmsModel;
- entry: PartialCmsContentEntryWithId;
+ entry: Partial;
options?: {
skipValidators?: string[];
};
@@ -88,10 +102,19 @@ export interface UpdateEntryRevisionParams {
};
}
+export interface UpdateSingletonEntryParams {
+ model: CmsModel;
+ entry: PartialCmsContentEntryWithId;
+ options?: {
+ skipValidators?: string[];
+ };
+}
+
export interface PublishEntryRevisionParams {
model: CmsModel;
id: string;
}
+
export interface DeleteEntryParams {
model: CmsModel;
id: string;
@@ -107,6 +130,15 @@ export interface GetEntryParams {
id: string;
}
+export interface ListEntryRevisionParams {
+ model: CmsModel;
+ id: string;
+}
+
+export interface GetSingletonEntryParams {
+ model: CmsModel;
+}
+
export interface BulkActionParams {
model: CmsModel;
action: string;
@@ -116,13 +148,19 @@ export interface BulkActionParams {
export interface CmsContext {
getApolloClient(locale: string): ApolloClient;
+
createApolloClient: CmsProviderProps["createApolloClient"];
apolloClient: ApolloClient;
getEntry: (params: GetEntryParams) => Promise;
+ listEntryRevisions: (params: ListEntryRevisionParams) => Promise;
+ getSingletonEntry: (params: GetSingletonEntryParams) => Promise;
createEntry: (params: CreateEntryParams) => Promise;
createEntryRevisionFrom: (
params: CreateEntryRevisionFromParams
) => Promise;
+ updateSingletonEntry: (
+ params: UpdateSingletonEntryParams
+ ) => Promise;
updateEntryRevision: (
params: UpdateEntryRevisionParams
) => Promise;
@@ -194,7 +232,67 @@ export const CmsProvider = (props: CmsProviderProps) => {
if (!response.data) {
return {
error: {
- message: "Missing response data on Get Entry query.",
+ message: "Missing response data on getEntry query.",
+ code: "MISSING_RESPONSE_DATA",
+ data: {}
+ }
+ };
+ }
+
+ const { data, error } = response.data.content;
+
+ if (error) {
+ return { error };
+ }
+
+ return {
+ entry: data as CmsContentEntry
+ };
+ },
+ listEntryRevisions: async ({ model, id }) => {
+ const query = createRevisionsQuery(model);
+
+ const response = await value.apolloClient.query<
+ CmsEntriesListRevisionsQueryResponse,
+ CmsEntriesListRevisionsQueryVariables
+ >({
+ query,
+ variables: { id },
+ fetchPolicy: "network-only"
+ });
+
+ if (!response.data) {
+ return {
+ error: {
+ message: "Missing response data on getRevisions query.",
+ code: "MISSING_RESPONSE_DATA",
+ data: {}
+ }
+ };
+ }
+
+ const { data, error } = response.data.revisions;
+
+ if (error) {
+ return { error };
+ }
+
+ return {
+ revisions: data as CmsContentEntryRevision[]
+ };
+ },
+ getSingletonEntry: async ({ model }) => {
+ const query = createReadSingletonQuery(model);
+
+ const response = await value.apolloClient.query({
+ query,
+ fetchPolicy: getFetchPolicy(model)
+ });
+
+ if (!response.data) {
+ return {
+ error: {
+ message: "Missing response data on getSingletonEntry query.",
code: "MISSING_RESPONSE_DATA",
data: {}
}
@@ -316,6 +414,42 @@ export const CmsProvider = (props: CmsProviderProps) => {
entry: data as CmsContentEntry
};
},
+ updateSingletonEntry: async ({ model, entry, options }) => {
+ const mutation = createUpdateSingletonMutation(model);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { id, ...input } = entry;
+ const response = await value.apolloClient.mutate<
+ CmsEntryUpdateSingletonMutationResponse,
+ CmsEntryUpdateSingletonMutationVariables
+ >({
+ mutation,
+ variables: {
+ data: input,
+ options
+ },
+ fetchPolicy: getFetchPolicy(model)
+ });
+
+ if (!response.data) {
+ return {
+ error: {
+ message: "Missing response data on updateSingletonEntry mutation.",
+ code: "MISSING_RESPONSE_DATA",
+ data: {}
+ }
+ };
+ }
+
+ const { data, error } = response.data.content;
+
+ if (error) {
+ return { error };
+ }
+
+ return {
+ entry: data as CmsContentEntry
+ };
+ },
publishEntryRevision: async ({ model, id }) => {
const mutation = createPublishMutation(model);
const response = await value.apolloClient.mutate<
diff --git a/packages/app-headless-cms/src/admin/hooks/usePersistEntry.ts b/packages/app-headless-cms/src/admin/hooks/usePersistEntry.ts
new file mode 100644
index 00000000000..7f10e7c363e
--- /dev/null
+++ b/packages/app-headless-cms/src/admin/hooks/usePersistEntry.ts
@@ -0,0 +1,49 @@
+import { useContentEntry } from "~/admin/views/contentEntries/hooks";
+import { CmsContentEntry } from "@webiny/app-headless-cms-common/types";
+import { PartialCmsContentEntryWithId } from "~/admin/contexts/Cms";
+import { useCallback } from "react";
+
+interface UsePersistEntryOptions {
+ addItemToListCache?: boolean;
+}
+
+interface PersistEntryOptions {
+ skipValidators?: string[];
+ createNewRevision?: boolean;
+}
+
+export function usePersistEntry({ addItemToListCache }: UsePersistEntryOptions) {
+ const contentEntry = useContentEntry();
+
+ const persistEntry = useCallback(
+ (entry: Partial, persistOptions?: PersistEntryOptions) => {
+ if (!entry.id) {
+ return contentEntry.createEntry({
+ entry,
+ options: {
+ skipValidators: persistOptions?.skipValidators,
+ addItemToListCache
+ }
+ });
+ }
+
+ if (!persistOptions?.createNewRevision) {
+ return contentEntry.updateEntryRevision({
+ entry: entry as PartialCmsContentEntryWithId,
+ options: { skipValidators: persistOptions?.skipValidators }
+ });
+ }
+
+ const { id, ...input } = entry;
+
+ return contentEntry.createEntryRevisionFrom({
+ id,
+ input,
+ options: { skipValidators: persistOptions?.skipValidators }
+ });
+ },
+ [addItemToListCache, contentEntry]
+ );
+
+ return { persistEntry };
+}
diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/DateTimeWithTimezone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/DateTimeWithTimezone.tsx
index 3c1ab4350f4..20a91a5a6c1 100644
--- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/DateTimeWithTimezone.tsx
+++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dateTime/DateTimeWithTimezone.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from "react";
+import React from "react";
import { Input } from "./Input";
import { Select } from "./Select";
import { Grid, Cell } from "@webiny/ui/Grid";
@@ -8,7 +8,9 @@ import {
DEFAULT_TIMEZONE,
getCurrentDate,
getCurrentLocalTime,
- getCurrentTimeZone
+ getCurrentTimeZone,
+ getHHmmss,
+ getHHmm
} from "./utils";
import { CmsModelField } from "~/types";
import { BindComponentRenderProp } from "@webiny/form";
@@ -37,11 +39,11 @@ const parseDateTime = (value?: string): Pick & { rest: string } =
};
};
-const parseTime = (value?: string): Pick => {
+const parseTime = (value?: string, defaultTimeZone?: string): Pick => {
if (!value) {
return {
time: "",
- timezone: ""
+ timezone: defaultTimeZone || ""
};
}
const sign = value.includes("+") ? "+" : "-";
@@ -62,20 +64,15 @@ export const DateTimeWithTimezone = ({ bind, trailingIcon, field }: DateTimeWith
const defaultTimeZone = getCurrentTimeZone() || DEFAULT_TIMEZONE;
// "2020-05-18T09:00+10:00"
- const initialValue = getDefaultFieldValue(field, bind, () => {
- const date = new Date();
- return `${getCurrentDate(date)}T${getCurrentLocalTime(date)}${defaultTimeZone}`;
- });
- const { date, rest } = parseDateTime(initialValue);
- const { time, timezone = defaultTimeZone } = parseTime(rest);
+ const value =
+ bind.value ||
+ getDefaultFieldValue(field, bind, () => {
+ const date = new Date();
+ return `${getCurrentDate(date)}T${getCurrentLocalTime(date)}${defaultTimeZone}`;
+ });
- const bindValue = bind.value || "";
- useEffect(() => {
- if (!date || !time || !timezone || bindValue === initialValue) {
- return;
- }
- bind.onChange(initialValue);
- }, [bindValue]);
+ const { date, rest } = parseDateTime(value);
+ const { time, timezone } = parseTime(rest, defaultTimeZone);
const cellSize = trailingIcon ? 3 : 4;
@@ -93,11 +90,8 @@ export const DateTimeWithTimezone = ({ bind, trailingIcon, field }: DateTimeWith
}
return bind.onChange("");
}
- return bind.onChange(
- `${value}T${time || getCurrentLocalTime()}${
- timezone || defaultTimeZone
- }`
- );
+
+ return bind.onChange(`${value}T${getHHmmss(time)}${timezone}`);
}
}}
field={{
@@ -111,7 +105,7 @@ export const DateTimeWithTimezone = ({ bind, trailingIcon, field }: DateTimeWith
{
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
|