From fc7c162a5cb6160b9da4f4fdee4c4c8039c8d8ad Mon Sep 17 00:00:00 2001 From: Vitalii Nobis Date: Fri, 12 Jan 2024 09:20:37 +0000 Subject: [PATCH] fix: improved ui for rules tabs, refactored validation for rules --- .github/workflows/pullRequests.yml | 2 + .github/workflows/wac/pullRequests.wac.ts | 4 + .gitignore | 2 + .prettierignore | 1 + apps/api/fileManager/download/package.json | 15 - apps/api/fileManager/download/src/index.ts | 15 - apps/api/fileManager/download/tsconfig.json | 21 - .../api/fileManager/download/webiny.config.ts | 8 - apps/api/fileManager/transform/package.json | 14 - apps/api/fileManager/transform/src/index.ts | 6 - apps/api/fileManager/transform/tsconfig.json | 18 - .../fileManager/transform/webiny.config.ts | 13 - apps/api/graphql/package.json | 4 +- apps/api/graphql/src/index.ts | 13 +- .../api/graphql/src/plugins/continuingTask.ts | 58 + .../graphql/src/plugins/countDynamoDbTask.ts | 25 + apps/api/graphql/src/types.ts | 2 + apps/api/graphql/tsconfig.json | 12 +- .../admin/pageBuilder/menus/menuCrud.cy.ts | 59 + .../admin/pageBuilder/menus/menuItems.cy.ts | 208 ++++ .../menus/pagesListMenuItemType.cy.ts | 172 +++ .../menus/searchAndSortMenus.cy.js | 135 -- .../menus/searchAndSortMenus.cy.ts | 145 +++ cypress-tests/cypress/support/commands.js | 13 +- .../support/login/authenticateWithCognito.ts | 9 +- cypress-tests/cypress/support/login/index.ts | 6 +- .../support/pageBuilder/pbClearMainMenu.ts | 61 + .../support/pageBuilder/pbCreateMenu.js | 16 - .../support/pageBuilder/pbCreateMenu.ts | 39 + .../support/pageBuilder/pbCreatePage.js | 16 - .../support/pageBuilder/pbCreatePage.ts | 38 + .../support/pageBuilder/pbDeleteAllMenus.ts | 74 ++ .../support/pageBuilder/pbDeleteAllPages.ts | 42 + .../pageBuilder/pbDeleteAllTemplates.ts | 25 +- .../support/pageBuilder/pbDeleteMenu.js | 16 - .../support/pageBuilder/pbDeleteMenu.ts | 35 + .../support/pageBuilder/pbDeletePage.js | 16 - .../support/pageBuilder/pbDeletePage.ts | 28 + .../support/pageBuilder/pbListMenus.ts | 36 + .../support/pageBuilder/pbListPages.js | 16 - .../support/pageBuilder/pbListPages.ts | 54 + .../support/pageBuilder/pbPublishPage.js | 16 - .../support/pageBuilder/pbPublishPage.ts | 28 + .../support/pageBuilder/pbUpdatePage.js | 16 - .../support/pageBuilder/pbUpdatePage.ts | 30 + .../support/pageBuilder/reloadUntil.ts | 75 ++ cypress-tests/cypress/support/utils.ts | 93 +- cypress-tests/package.json | 2 + cypress-tests/tsconfig.json | 1 + package.json | 3 + .../__tests__/snapshots/customAppsSchema.ts | 157 +-- .../__tests__/snapshots/defaultAppsSchema.ts | 157 +-- .../api-aco/src/utils/pickEntryFieldValues.ts | 28 +- .../src/createAdminUsers/users.validation.ts | 26 +- .../src/plugins/graphql/comment.gql.ts | 5 - .../src/plugins/graphql/contentReview.gql.ts | 9 +- .../src/plugins/hooks/updateTotalComments.ts | 2 +- .../plugins/HeadlessCMSGraphQL.ts | 3 +- .../api-apw/src/utils/pickEntryFieldValues.ts | 13 +- packages/api-background-tasks-ddb/.babelrc.js | 1 + packages/api-background-tasks-ddb/LICENSE | 21 + packages/api-background-tasks-ddb/README.md | 6 + .../api-background-tasks-ddb/package.json | 33 + .../api-background-tasks-ddb/src/index.ts | 6 + .../tsconfig.build.json | 15 + .../api-background-tasks-ddb/tsconfig.json | 19 + .../api-background-tasks-ddb/webiny.config.js | 8 + packages/api-background-tasks-es/.babelrc.js | 1 + packages/api-background-tasks-es/LICENSE | 21 + packages/api-background-tasks-es/README.md | 6 + packages/api-background-tasks-es/package.json | 34 + packages/api-background-tasks-es/src/index.ts | 11 + .../tsconfig.build.json | 16 + .../api-background-tasks-es/tsconfig.json | 25 + .../api-background-tasks-es/webiny.config.js | 8 + packages/api-background-tasks-os/.babelrc.js | 1 + packages/api-background-tasks-os/LICENSE | 21 + packages/api-background-tasks-os/README.md | 6 + packages/api-background-tasks-os/package.json | 34 + packages/api-background-tasks-os/src/index.ts | 11 + .../tsconfig.build.json | 16 + .../api-background-tasks-os/tsconfig.json | 25 + .../api-background-tasks-os/webiny.config.js | 8 + packages/api-elasticsearch-tasks/.babelrc.js | 1 + packages/api-elasticsearch-tasks/LICENSE | 21 + packages/api-elasticsearch-tasks/README.md | 10 + .../__tests__/helpers/helpers.ts | 72 ++ .../__tests__/helpers/tenancySecurity.ts | 69 ++ .../__tests__/helpers/useHandler.ts | 88 ++ .../__tests__/mocks/context.ts | 33 + .../__tests__/mocks/elasticsearch.ts | 74 ++ .../__tests__/mocks/event.ts | 14 + .../__tests__/mocks/identity.ts | 9 + .../__tests__/mocks/indexManager.ts | 16 + .../__tests__/mocks/store.ts | 14 + .../__tests__/mocks/task.ts | 18 + .../__tests__/settings/indexManager.test.ts | 47 + .../tasks/reindexing/reindexing.test.ts | 172 +++ .../reindexingTaskDefinition.test.ts | 10 + .../reindexing/reindexingTaskRunner.test.ts | 121 ++ .../api-elasticsearch-tasks/jest.setup.js | 11 + packages/api-elasticsearch-tasks/package.json | 54 + .../src/definitions/entry.ts | 30 + .../src/definitions/index.ts | 2 + .../src/definitions/table.ts | 19 + .../src/errors/IndexSettingsGetError.ts | 14 + .../src/errors/IndexSettingsSetError.ts | 14 + .../src/errors/IndexingDisableError.ts | 1 + .../src/errors/IndexingEnableError.ts | 1 + .../src/errors/index.ts | 4 + .../src/helpers/scan.ts | 21 + packages/api-elasticsearch-tasks/src/index.ts | 10 + .../src/settings/DisableIndexing.ts | 26 + .../src/settings/EnableIndexing.ts | 21 + .../src/settings/IndexManager.ts | 52 + .../src/settings/IndexSettingsManager.ts | 44 + .../src/settings/index.ts | 1 + .../src/settings/types.ts | 8 + .../src/tasks/Manager.ts | 83 ++ .../src/tasks/index.ts | 1 + .../tasks/reindexing/ReindexingTaskRunner.ts | 143 +++ .../src/tasks/reindexing/index.ts | 1 + .../reindexing/reindexingTaskDefinition.ts | 37 + packages/api-elasticsearch-tasks/src/types.ts | 61 + .../tsconfig.build.json | 29 + .../api-elasticsearch-tasks/tsconfig.json | 64 + .../api-elasticsearch-tasks/webiny.config.js | 8 + packages/api-elasticsearch/src/client.ts | 2 + packages/api-file-manager-s3/package.json | 6 +- .../src/assetDelivery/assetDeliveryConfig.ts | 46 + .../src/assetDelivery/createAssetDelivery.ts | 14 + .../assetDelivery/s3/S3AssetMetadataReader.ts | 44 + .../src/assetDelivery/s3/S3AssetResolver.ts | 37 + .../src/assetDelivery/s3/S3ContentsReader.ts | 25 + .../src/assetDelivery/s3/S3ErrorAssetReply.ts | 10 + .../src/assetDelivery/s3/S3OutputStrategy.ts | 44 + .../assetDelivery/s3/S3RedirectAssetReply.ts | 15 + .../assetDelivery/s3/S3StreamAssetReply.ts | 14 + .../src/assetDelivery/s3/SharpTransform.ts | 165 +++ .../s3/transformation/AssetKeyGenerator.ts | 20 + .../transformation/CallableContentsReader.ts | 17 + .../s3/transformation/WidthCollection.ts | 23 + .../s3/transformation}/legacyUtils.ts | 6 - .../assetDelivery/s3/transformation/utils.ts | 40 + .../src/flushCdnCache/CdnPathsGenerator.ts | 7 + .../src/flushCdnCache/InvalidateCacheTask.ts | 97 ++ .../flushCdnCache/flushCacheOnFileDelete.ts | 30 + .../flushCdnCache/flushCacheOnFileUpdate.ts | 37 + .../src/flushCdnCache/index.ts | 7 + .../invalidateCacheTaskDefinition.ts | 15 + packages/api-file-manager-s3/src/index.ts | 7 +- .../src/plugins/addFileMetadata.ts | 27 + .../api-file-manager-s3/tsconfig.build.json | 2 + packages/api-file-manager-s3/tsconfig.json | 6 + .../__tests__/handlers/download.disabled.ts | 243 ++++ .../__tests__/handlers/download.test.ts | 246 ---- .../__tests__/mocks/file.sdl.ts | 157 +-- packages/api-file-manager/package.json | 2 +- .../src/FileManagerContextSetup.ts | 4 +- .../src/cmsFileStorage/CmsFilesStorage.ts | 34 +- .../createFileManagerPlugins.ts | 19 +- .../src/cmsFileStorage/createModelField.ts | 2 + .../src/cmsFileStorage/file.model.ts | 121 +- .../src/createFileManager/files.crud.ts | 1 - .../AliasAssetRequestResolver.ts | 71 ++ .../src/delivery/AssetDelivery/Asset.ts | 79 ++ .../AssetDelivery/AssetDeliveryConfig.ts | 151 +++ .../delivery/AssetDelivery/AssetRequest.ts | 45 + .../FilesAssetRequestResolver.ts | 29 + .../AssetDelivery/NullAssetOutputStrategy.ts | 8 + .../delivery/AssetDelivery/NullAssetReply.ts | 10 + .../AssetDelivery/NullAssetResolver.ts | 8 + .../AssetDelivery/NullRequestResolver.ts | 7 + .../AssetDelivery/SetCacheControlHeaders.ts | 26 + .../AssetDelivery/SetResponseHeaders.ts | 46 + .../abstractions/AssetContentsReader.ts | 5 + .../abstractions/AssetOutputStrategy.ts | 5 + .../abstractions/AssetProcessor.ts | 5 + .../AssetDelivery/abstractions/AssetReply.ts | 53 + .../abstractions/AssetRequestResolver.ts | 6 + .../abstractions/AssetResolver.ts | 5 + .../AssetTransformationStrategy.ts | 5 + .../createAssetDeliveryPluginLoader.ts | 9 + .../privateFiles/AssetAuthorizer.ts | 5 + .../privateFiles/NotAuthorizedAssetReply.ts | 15 + .../NotAuthorizedOutputStrategy.ts | 8 + .../PrivateAuthenticatedAuthorizer.ts | 25 + .../privateFiles/PrivateCache.ts | 13 + .../PrivateFileAssetRequestResolver.ts | 35 + .../PrivateFilesAssetProcessor.ts | 79 ++ .../AssetDelivery/privateFiles/PublicCache.ts | 13 + .../RedirectToPrivateUrlOutputStrategy.ts | 23 + .../RedirectToPublicUrlOutputStrategy.ts | 23 + .../privateFiles/internalIdentity.ts | 12 + .../PassthroughAssetProcessor.ts | 7 + .../PassthroughAssetTransformationStrategy.ts | 7 + .../TransformationAssetProcessor.ts | 20 + .../api-file-manager/src/delivery/index.ts | 15 + .../src/delivery/setupAssetDelivery.ts | 170 +++ .../src/handlers/download/byAlias.ts | 102 -- .../src/handlers/download/byExactKey.ts | 54 - .../download/extractFileInformation.ts | 19 - .../src/handlers/download/getS3Object.ts | 86 -- .../src/handlers/download/index.ts | 2 - .../managers => manage}/imageManager.ts | 6 +- .../src/handlers/manage/index.ts | 4 +- .../src/handlers/manage/legacyUtils.ts | 40 + .../handlers/{transform => manage}/utils.ts | 0 .../src/handlers/transform/index.ts | 93 -- .../handlers/transform/loaders/imageLoader.ts | 109 -- .../src/handlers/transform/loaders/index.ts | 3 - .../loaders/sanitizeImageTransformations.ts | 51 - .../src/handlers/transform/managers/index.ts | 3 - .../src/handlers/transform/optimizeImage.ts | 34 - .../src/handlers/transform/transformImage.ts | 30 - packages/api-file-manager/src/index.ts | 7 +- packages/api-file-manager/src/types.ts | 5 +- packages/api-file-manager/src/types/file.ts | 9 + packages/api-file-manager/tsconfig.build.json | 2 +- packages/api-file-manager/tsconfig.json | 6 +- .../src/plugins/graphql/form.ts | 18 +- packages/api-form-builder/src/types.ts | 9 +- .../indexing/objectIndexing.test.ts | 1 + .../src/definitions/entry.ts | 48 +- .../src/elasticsearch/indexing/index.ts | 4 +- .../elasticsearch/indexing/jsonIndexing.ts | 17 + .../elasticsearch/indexing/objectIndexing.ts | 8 +- .../operations/entry/elasticsearch/fields.ts | 71 -- .../src/operations/entry/index.ts | 31 +- packages/api-headless-cms-ddb-es/src/types.ts | 3 +- .../entry/filtering/createFields.test.ts | 160 +-- .../src/definitions/entry.ts | 52 +- .../entry/filtering/systemFields.ts | 42 - .../src/operations/entry/index.ts | 26 +- .../contentEntry.crud.validation.test.ts | 10 +- .../mocks/field.dateTime.ts | 6 +- .../contentEntriesOnByMetaFields.test.ts | 145 +-- ...tentEntriesOnByMetaFieldsOverrides.test.ts | 17 +- ...entEntriesOnByMetaFieldsPublishing.test.ts | 227 ++-- .../contentEntryCustomDates.test.ts | 96 +- .../contentEntryCustomIdentities.test.s.ts | 28 +- .../contentAPI/contentEntryMetaField.test.ts | 2 +- .../contentAPI/entryPagination.test.ts | 8 +- .../contentAPI/fieldValidations.test.ts | 12 +- .../__tests__/contentAPI/filtering.test.ts | 22 +- .../contentAPI/latestEntries.test.ts | 21 +- .../contentAPI/predefinedValues.test.ts | 20 +- .../__tests__/contentAPI/refField.test.ts | 10 +- .../__tests__/contentAPI/references.test.ts | 11 +- .../contentAPI/republish.entries.test.ts | 17 +- .../resolvers.apiKey.manage.test.ts | 8 +- .../contentAPI/resolvers.manage.test.ts | 34 +- .../contentAPI/richTextField.test.ts | 32 +- .../contentAPI/snapshots/category.manage.ts | 228 ++-- .../contentAPI/snapshots/category.read.ts | 185 ++- .../contentAPI/snapshots/page.manage.ts | 232 ++-- .../contentAPI/snapshots/page.read.ts | 185 ++- .../contentAPI/snapshots/product.manage.ts | 232 ++-- .../contentAPI/snapshots/product.read.ts | 185 ++- .../contentAPI/snapshots/review.manage.ts | 232 ++-- .../contentAPI/snapshots/review.read.ts | 185 ++- .../contentAPI/storageTransform.test.ts | 3 + .../mocks/fieldIdStorageConverter.ts | 11 +- .../__tests__/helpers/renderSortEnum.test.ts | 48 +- .../__tests__/storageOperations/helpers.ts | 8 +- .../testHelpers/useArticleManageHandler.ts | 9 +- .../testHelpers/useArticleReadHandler.ts | 8 +- .../testHelpers/useAuthorManageHandler.ts | 6 +- .../testHelpers/useBugManageHandler.ts | 6 +- .../testHelpers/useCategoryManageHandler.ts | 13 +- .../testHelpers/useFruitManageHandler.ts | 6 +- .../testHelpers/useFruitReadHandler.ts | 5 +- .../testHelpers/useProductManageHandler.ts | 6 +- .../testHelpers/useProductReadHandler.ts | 3 + .../testHelpers/useReviewManageHandler.ts | 6 +- .../useTestModelHandler/manageGql.ts | 29 +- .../testHelpers/useWrapManageHandler.ts | 26 +- packages/api-headless-cms/src/constants.ts | 69 +- .../src/crud/contentEntry.crud.ts | 26 +- .../entryDataFactories/createEntryData.ts | 51 +- .../createEntryRevisionFromData.ts | 51 +- .../createPublishEntryData.ts | 57 +- .../createRepublishEntryData.ts | 31 +- .../createUnpublishEntryData.ts | 18 +- .../createUpdateEntryData.ts | 52 +- .../src/export/crud/imports/importData.ts | 36 +- .../src/export/crud/imports/validateGroups.ts | 20 +- .../src/export/crud/imports/validateInput.ts | 27 +- .../src/export/crud/imports/validateModels.ts | 21 +- packages/api-headless-cms/src/export/types.ts | 2 + .../src/graphql/schema/baseSchema.ts | 14 - .../src/graphql/schema/contentEntries.ts | 92 +- .../src/graphql/schema/createManageSDL.ts | 33 +- .../src/graphql/schema/createReadSDL.ts | 8 - .../schema/resolvers/manage/resolvePublish.ts | 5 +- .../src/graphqlFields/index.ts | 2 + .../src/graphqlFields/json.ts | 35 + packages/api-headless-cms/src/index.ts | 6 +- .../src/plugins/CmsModelPlugin.ts | 15 +- .../src/plugins/StorageTransformPlugin.ts | 4 +- .../api-headless-cms/src/storage/index.ts | 11 + packages/api-headless-cms/src/storage/json.ts | 14 + packages/api-headless-cms/src/types.ts | 267 ++-- .../src/utils/renderListFilterFields.ts | 48 - .../src/utils/renderSortEnum.ts | 13 - .../api-i18n/src/graphql/crud/locales.crud.ts | 7 +- packages/api-i18n/src/types.ts | 2 +- .../src/client.ts | 5 - .../src/createAdminUsersHooks.ts | 42 +- packages/api-security/package.json | 4 +- packages/api-security/src/createSecurity.ts | 44 +- packages/api-security/src/index.ts | 1 + .../src/plugins/authenticateUsingCookie.ts | 63 + .../plugins/authenticateUsingHttpHeader.ts | 26 +- .../api-security/src/plugins/secureHeaders.ts | 35 + packages/api-security/src/types.ts | 8 + .../api-security/src/utils/IdentityValue.ts | 37 + packages/api-wcp/src/index.ts | 2 +- .../api/__tests__/ServiceDiscovery.test.ts | 67 + packages/api/__tests__/setup/setupAfterEnv.js | 7 + packages/api/jest-dynalite-config.js | 2 + .../api/{jest.config.js => jest.setup.js} | 0 packages/api/package.json | 1 + packages/api/src/ServiceDiscovery.ts | 77 ++ packages/api/src/index.ts | 1 + packages/api/tsconfig.build.json | 5 +- packages/api/tsconfig.json | 4 +- .../components/Table/Columns/ColumnMapper.ts | 2 +- packages/app-aco/src/config/AcoConfig.tsx | 10 +- packages/app-aco/src/config/record/Action.tsx | 52 + packages/app-aco/src/config/record/index.ts | 9 + .../OptionsMenu/OptionsMenuLink.tsx | 48 + .../src/components/OptionsMenu/index.ts | 1 + .../OptionsMenu/useOptionsMenuItem.tsx | 4 +- packages/app-admin/src/components/Wcp.tsx | 16 + packages/app-admin/src/index.ts | 1 + packages/app-admin/src/types.ts | 3 + packages/app-apw/src/index.tsx | 26 +- .../BulkActions/ActionEdit/ActionEdit.tsx | 113 +- .../ActionEdit/ActionEditPresenter.test.ts | 6 +- .../ActionEdit/ActionEditPresenter.ts | 65 +- .../BatchEditorDialog/BatchEditor.tsx | 4 +- .../BatchEditorDialog/BatchEditorDialog.tsx | 18 +- .../BatchEditorDialogPresenter.test.ts | 22 +- .../BatchEditorDialogPresenter.tsx | 116 +- .../BatchEditorDialog/FieldRenderer.tsx | 49 +- .../BatchEditorDialog/Operation.tsx | 12 +- .../ActionEdit/GraphQLInputMapper.test.ts | 282 ++++- .../ActionEdit/GraphQLInputMapper.ts | 18 +- .../ActionEdit/domain/BatchMapper.ts | 2 +- .../BulkActions/ActionEdit/domain/Field.ts | 2 +- .../ActionEdit/useActionEditWorker.ts | 77 ++ .../FileDetails/components/Extensions.tsx | 38 +- .../src/components/Table/Actions/CopyFile.tsx | 20 + .../components/Table/Actions/DeleteFile.tsx | 28 + .../src/components/Table/Actions/EditFile.tsx | 20 + .../src/components/Table/Actions/MoveFile.tsx | 20 + .../src/components/Table/Actions/index.ts | 4 + .../components/Table/Cells/CellActions.tsx | 23 +- .../components/Table/Cells/Cells.styled.tsx | 5 - .../components/Table/FolderActionDelete.tsx | 0 .../src/components/Table/FolderActionEdit.tsx | 0 .../Table/FolderActionManagePermissions.tsx | 0 .../src/components/Table/Name.tsx | 0 .../src/components/Table/RecordActionCopy.tsx | 28 - .../components/Table/RecordActionDelete.tsx | 34 - .../src/components/Table/RecordActionEdit.tsx | 26 - .../src/components/Table/RecordActionMove.tsx | 23 - .../src/components/Table/index.tsx | 1 + .../src/components/Table/styled.tsx | 11 - .../src/components/fields/AccessControl.tsx | 48 + .../components => fields}/Aliases.tsx | 4 +- .../components => fields}/Name.tsx | 2 +- .../components => fields}/Tags.tsx | 7 +- .../src/components/fields/index.ts | 4 + .../fields/useAccessControlField.tsx | 37 + .../components/fields/useFileOrUndefined.ts | 9 + .../FileManagerView/FileManagerViewConfig.tsx | 1 + .../Browser/BulkEditField.tsx | 25 + .../configComponents/Browser/FileAction.tsx | 19 + .../configComponents/Browser/Table/Column.tsx | 4 +- .../configComponents/Browser/index.ts | 6 + .../FileManagerViewProvider/useTags.ts | 12 +- .../src/modules/FileManagerRenderer/index.tsx | 29 +- .../components/FormEditor/Context/graphql.ts | 14 +- .../Tabs/EditTab/EditFieldDialog.tsx | 25 +- .../EditFieldDialog/DefaultBehaviour.tsx | 6 +- .../EditFieldDialog/RuleActionSelect.tsx | 29 +- .../EditFieldDialog/RulesConditions.tsx | 167 ++- .../Tabs/EditTab/EditFieldDialog/RulesTab.tsx | 288 ----- .../EditTab/EditFieldDialog/RulesTab/Rule.tsx | 50 + .../EditFieldDialog/RulesTab/Rules.tsx | 137 ++ .../EditFieldDialog/RulesTab/RulesTab.tsx | 87 ++ .../ConditionGroupField/ConditionGroup.tsx | 10 +- .../EditFormStepDialog/EditFormStepDialog.tsx | 5 +- .../EditFormStepDialog/RuleCondition.tsx | 81 +- .../FormStep/EditFormStepDialog/RulesTab.tsx | 263 ---- .../RulesTab/AddRuleCondition.tsx | 36 + .../EditFormStepDialog/RulesTab/Rule.tsx | 55 + .../RulesTab/RuleAction.tsx | 40 + .../RulesTab/RuleCondition.tsx | 33 + .../EditFormStepDialog/RulesTab/Rules.tsx | 140 +++ .../EditFormStepDialog/RulesTab/RulesTab.tsx | 54 + ...eActionSelect.tsx => SelectRuleAction.tsx} | 47 +- .../fieldsValidationConditions.ts | 4 + .../FormStepWithFields/FormStepRowField.tsx | 2 +- .../FormEditor/Tabs/EditTab/Styled.ts | 50 +- .../src/components/Form/FormRender.tsx | 23 +- .../Form/functions/getNextStepIndex.ts | 97 +- .../src/components/Form/functions/index.ts | 2 + .../Form/functions/onFormDataChange.ts | 8 + .../components/Form/functions/usePrevious.ts | 11 + .../src/components/Form/graphql.ts | 7 +- packages/app-form-builder/src/types.ts | 9 +- .../src/validators/endsWith.ts | 9 + .../app-form-builder/src/validators/gt.ts | 11 + .../src/validators/includes.ts | 7 + .../app-form-builder/src/validators/index.ts | 8 + .../app-form-builder/src/validators/is.ts | 11 + .../app-form-builder/src/validators/lt.ts | 11 + .../src/validators/startsWith.ts | 9 + .../src/entries.graphql.ts | 7 +- .../src/prepareFormData.ts | 20 +- .../src/types/index.ts | 10 +- .../src/types/model.ts | 2 +- .../Table/Actions/ChangeEntryStatus.tsx | 36 + .../Table/Actions/DeleteEntry.tsx | 24 + .../Table/Actions/EditEntry.tsx | 24 + .../Table/Actions/MoveEntry.tsx | 19 + .../ContentEntries/Table/Actions/index.ts | 4 + .../Table/Cells/CellActions.tsx | 27 +- .../ContentEntries/Table/Cells/CellName.tsx | 32 +- .../Table/Cells/Cells.styled.tsx | 10 +- .../Table/Row/Record/RecordActionDelete.tsx | 80 -- .../Table/Row/Record/RecordActionEdit.tsx | 32 - .../Table/Row/Record/RecordActionMove.tsx | 26 - .../Table/Row/Record/RecordActionPublish.tsx | 92 -- .../ContentEntryForm/useContentEntryForm.ts | 3 +- .../list/Browser/EntryAction.tsx | 28 + .../list/Browser/Table/Column.tsx | 2 +- .../contentEntries/list/Browser/index.ts | 3 + .../app-headless-cms/src/admin/hooks/index.ts | 3 + .../src/admin/hooks/useChangeEntryStatus.tsx | 85 ++ .../src/admin/hooks/useDeleteEntry.tsx | 63 + .../src/admin/hooks/useEntry.tsx | 30 + .../ref/advanced/hooks/graphql.ts | 4 +- .../contentEntries/ContentEntriesModule.tsx | 14 +- .../ContentEntry/useRevision.tsx | 41 +- .../hooks/useContentEntriesList.tsx | 18 +- .../contentModels/importing/ImportContext.tsx | 40 +- .../views/contentModels/importing/graphql.ts | 34 +- .../app-page-builder-elements/package.json | 4 +- .../src/renderers/form/FormRender.tsx | 51 +- .../FormRender/functions/getNextStepIndex.ts | 96 +- .../src/renderers/form/dataLoaders/graphql.ts | 7 +- .../src/renderers/form/types.ts | 9 +- .../app-page-builder-elements/src/types.ts | 9 +- .../src/validators/endsWith.ts | 9 + .../src/validators/gt.ts | 11 + .../src/validators/includes.ts | 7 + .../src/validators/index copy.ts | 8 + .../src/validators/index.ts | 8 + .../src/validators/is.ts | 11 + .../src/validators/lt.ts | 11 + .../src/validators/startsWith.ts | 9 + .../tsconfig.build.json | 3 +- .../app-page-builder-elements/tsconfig.json | 6 +- .../Table/Table/Actions/ChangePageStatus.tsx | 42 + .../Table/Table/Actions/DeletePage.tsx | 26 + .../Table/Table/Actions/EditPage.tsx | 43 + .../Table/Table/Actions/MovePage.tsx | 20 + .../Table/Table/Actions/PreviewPage.tsx | 22 + .../components/Table/Table/Actions/index.ts | 5 + .../Table/Table/Cells/CellActions.tsx | 28 +- .../Table/Table/Cells/Cells.styled.tsx | 5 - .../Folder/FolderActionManagePermissions.tsx | 0 .../Table/Row/Record/RecordActionDelete.tsx | 36 - .../Table/Row/Record/RecordActionEdit.tsx | 75 -- .../Table/Row/Record/RecordActionMove.tsx | 30 - .../Table/Row/Record/RecordActionPreview.tsx | 53 - .../Table/Row/Record/RecordActionPublish.tsx | 89 -- .../src/admin/components/Table/Table/index.ts | 1 + .../admin/components/Table/Table/styled.tsx | 12 - .../config/pages/list/Browser/PageAction.tsx | 19 + .../pages/list/Browser/Table/Column.tsx | 4 +- .../admin/config/pages/list/Browser/index.ts | 3 + .../src/admin/hooks/useNavigatePage.ts | 10 +- .../src/admin/views/Pages/PagesModule.tsx | 12 +- .../views/Pages/hooks/useChangePageStatus.tsx | 43 + .../views/Pages/hooks/useCreatePageFrom.ts | 47 + .../src/admin/views/Pages/hooks/usePage.tsx | 30 + .../admin/views/Pages/hooks/usePreviewPage.ts | 36 + packages/app-page-builder/src/components.ts | 27 + packages/app-page-builder/src/index.ts | 2 + .../src/apolloClientFactory.ts | 3 +- packages/aws-layers/index.d.ts | 1 + packages/aws-layers/package.json | 1 + packages/aws-sdk/package.json | 1 + .../aws-sdk/src/client-eventbridge/index.ts | 5 +- .../aws-sdk/src/client-eventbridge/types.ts | 31 + packages/aws-sdk/src/client-sfn/index.ts | 45 + .../commands/deploy.js | 4 +- .../commands/deploy/buildPackages.js | 33 +- .../commands/destroy.js | 9 +- .../commands/output.js | 6 +- .../commands/pulumiRun.js | 12 +- .../commands/watch/output/browserOutput.js | 2 +- packages/cli/commands/telemetry/index.js | 12 +- packages/cli/commands/wcp/login.js | 7 +- packages/create-webiny-project/bin.js | 47 +- .../utils/createProject.js | 18 +- .../utils/getNpmVersion.js | 10 + .../template/common/tsconfig.build.json | 50 +- .../ddb-es/apps/api/graphql/package.json | 1 + .../ddb-es/apps/api/graphql/src/index.ts | 2 + .../ddb-os/apps/api/graphql/package.json | 1 + .../ddb-os/apps/api/graphql/src/index.ts | 5 +- .../ddb/apps/api/graphql/package.json | 1 + .../ddb/apps/api/graphql/src/index.ts | 2 + packages/db-dynamodb/src/utils/batchWrite.ts | 63 +- packages/db-dynamodb/src/utils/scan.ts | 4 +- packages/form/src/BindPrefix.tsx | 16 + packages/form/src/Form.tsx | 13 +- packages/form/src/index.ts | 1 + packages/handler-aws/src/execute.ts | 2 +- packages/handler-aws/src/index.ts | 1 + packages/handler-aws/src/sourceHandler.ts | 4 +- packages/handler-aws/src/types.ts | 4 +- packages/handler/__tests__/headers.test.ts | 74 ++ packages/handler/src/ResponseHeaders.ts | 64 + packages/handler/src/fastify.ts | 154 ++- packages/handler/src/index.ts | 3 + .../plugins/ModifyResponseHeadersPlugin.ts | 25 + packages/handler/src/types.ts | 2 +- .../migrations/5.36.0/001/ddb/001.test.ts | 1 - packages/migrations/src/ddb-es.ts | 6 +- packages/migrations/src/ddb.ts | 6 +- .../005/ddb-es/FileManager_5_39_0_005.ts | 212 ++++ .../src/migrations/5.39.0/005/ddb-es/index.ts | 1 + .../5.39.0/005/ddb/FileManager_5_39_0_005.ts | 157 +++ .../src/migrations/5.39.0/005/ddb/index.ts | 1 + .../5.39.0/005/utils/FileMetadata.ts | 60 + .../5.39.0/005/utils/createFileEntity.ts | 56 + .../5.39.0/005/utils/createLocaleEntity.ts | 25 + .../5.39.0/005/utils/createTenantEntity.ts | 6 + .../bundling/function/buildFunction.js | 4 +- .../bundling/function/webpack.config.js | 59 +- .../project-utils/configs/tsconfig.build.json | 51 + packages/project-utils/package.json | 1 + .../packages/createBabelConfigForNode.js | 14 +- .../packages/createBabelConfigForReact.js | 13 +- .../project-utils/testing/dynamodb/index.d.ts | 8 +- .../testing/elasticsearch/createClient.d.ts | 2 + .../src/apps/api/ApiBackgroundTask.ts | 142 +++ .../pulumi-aws/src/apps/api/ApiCloudfront.ts | 43 +- .../pulumi-aws/src/apps/api/ApiFileManager.ts | 62 +- .../pulumi-aws/src/apps/api/ApiGraphql.ts | 12 +- .../src/apps/api/backgroundTask/definition.ts | 160 +++ .../src/apps/api/backgroundTask/policy.ts | 43 + .../src/apps/api/backgroundTask/role.ts | 38 + .../src/apps/api/backgroundTask/types.ts | 114 ++ .../src/apps/api/createApiPulumiApp.ts | 57 +- packages/pulumi-aws/src/apps/api/index.ts | 1 + .../pulumi-aws/src/apps/common/CoreOutput.ts | 1 + .../src/apps/core/createCorePulumiApp.ts | 19 +- .../src/apps/react/createReactPulumiApp.ts | 7 +- .../src/apps/website/WebsitePrerendering.ts | 1 - .../apps/website/createWebsitePulumiApp.ts | 3 +- .../src/utils/addServiceManifestTableItem.ts | 45 + packages/pulumi-aws/src/utils/index.ts | 1 + .../src/utils/lambdaEnvVariables.ts | 27 +- .../src/utils/withServiceManifest.ts | 67 + packages/pulumi-sdk/src/Pulumi.ts | 4 +- packages/pulumi/src/createPulumiApp.ts | 16 +- packages/pulumi/src/types.ts | 13 +- .../api/fileManager/download/src/index.ts | 15 - .../api/fileManager/download/webiny.config.js | 9 - .../api/fileManager/transform/src/index.ts | 6 - .../fileManager/transform/webiny.config.js | 14 - packages/tasks/.babelrc.js | 1 + packages/tasks/LICENSE | 21 + packages/tasks/README.md | 10 + .../tasks/__tests__/crud/definitions.test.ts | 69 ++ packages/tasks/__tests__/crud/store.test.ts | 184 +++ packages/tasks/__tests__/crud/trigger.test.ts | 61 + .../__tests__/graphql/definitions.test.ts | 48 + packages/tasks/__tests__/graphql/logs.test.ts | 157 +++ .../tasks/__tests__/graphql/tasks.test.ts | 115 ++ .../__tests__/helpers/graphql/definitions.ts | 21 + .../tasks/__tests__/helpers/graphql/logs.ts | 46 + .../tasks/__tests__/helpers/graphql/tasks.ts | 60 + packages/tasks/__tests__/helpers/helpers.ts | 72 ++ .../__tests__/helpers/tenancySecurity.ts | 69 ++ .../__tests__/helpers/useGraphQLHandler.ts | 161 +++ .../tasks/__tests__/helpers/useHandler.ts | 56 + packages/tasks/__tests__/mocks/context.ts | 48 + packages/tasks/__tests__/mocks/definition.ts | 62 + packages/tasks/__tests__/mocks/event.ts | 15 + packages/tasks/__tests__/mocks/identity.ts | 9 + packages/tasks/__tests__/mocks/index.ts | 4 + packages/tasks/__tests__/mocks/response.ts | 42 + packages/tasks/__tests__/mocks/runner.ts | 31 + packages/tasks/__tests__/mocks/store.ts | 20 + packages/tasks/__tests__/mocks/task.ts | 38 + packages/tasks/__tests__/mocks/taskLog.ts | 18 + .../tasks/__tests__/mocks/taskResponse.ts | 6 + .../runner/taskEventValidation.test.ts | 304 +++++ .../__tests__/runner/taskManager.test.ts | 188 +++ .../__tests__/runner/taskManagerStore.test.ts | 128 ++ packages/tasks/__tests__/task/input.test.ts | 26 + packages/tasks/__tests__/task/plugin.test.ts | 156 +++ packages/tasks/__tests__/types.ts | 5 + packages/tasks/jest.setup.js | 11 + packages/tasks/package.json | 56 + packages/tasks/src/context.ts | 35 + .../tasks/src/crud/createEventBridgeEvent.ts | 72 ++ packages/tasks/src/crud/crud.tasks.ts | 254 ++++ packages/tasks/src/crud/definition.tasks.ts | 24 + packages/tasks/src/crud/model.ts | 280 +++++ packages/tasks/src/crud/trigger.tasks.ts | 137 ++ packages/tasks/src/crud/where.ts | 23 + .../tasks/src/graphql/checkPermissions.ts | 48 + packages/tasks/src/graphql/index.ts | 374 ++++++ packages/tasks/src/graphql/utils.ts | 30 + packages/tasks/src/handler/index.ts | 78 ++ packages/tasks/src/handler/register.ts | 16 + packages/tasks/src/handler/types.ts | 21 + packages/tasks/src/index.ts | 15 + .../tasks/src/response/DatabaseResponse.ts | 157 +++ packages/tasks/src/response/Response.ts | 87 ++ .../src/response/ResponseAbortedResult.ts | 17 + .../src/response/ResponseContinueResult.ts | 23 + .../tasks/src/response/ResponseDoneResult.ts | 19 + .../tasks/src/response/ResponseErrorResult.ts | 19 + packages/tasks/src/response/TaskResponse.ts | 68 + .../src/response/abstractions/Response.ts | 37 + .../abstractions/ResponseAbortedResult.ts | 6 + .../abstractions/ResponseBaseResult.ts | 9 + .../abstractions/ResponseContinueResult.ts | 22 + .../abstractions/ResponseDoneResult.ts | 14 + .../abstractions/ResponseErrorResult.ts | 21 + .../src/response/abstractions/TaskResponse.ts | 46 + .../tasks/src/response/abstractions/index.ts | 7 + packages/tasks/src/response/index.ts | 7 + packages/tasks/src/runner/TaskControl.ts | 157 +++ .../tasks/src/runner/TaskEventValidation.ts | 26 + packages/tasks/src/runner/TaskManager.ts | 123 ++ packages/tasks/src/runner/TaskManagerStore.ts | 122 ++ packages/tasks/src/runner/TaskRunner.ts | 88 ++ .../src/runner/abstractions/TaskControl.ts | 12 + .../abstractions/TaskEventValidation.ts | 7 + .../src/runner/abstractions/TaskManager.ts | 6 + .../runner/abstractions/TaskManagerStore.ts | 60 + .../src/runner/abstractions/TaskRunner.ts | 16 + .../tasks/src/runner/abstractions/index.ts | 5 + packages/tasks/src/runner/index.ts | 1 + .../src/runner/utils/getErrorProperties.ts | 11 + .../src/runner/utils/getObjectProperties.ts | 12 + packages/tasks/src/task/index.ts | 2 + packages/tasks/src/task/input.ts | 27 + packages/tasks/src/task/plugin.ts | 94 ++ packages/tasks/src/types.ts | 327 +++++ packages/tasks/tsconfig.build.json | 27 + packages/tasks/tsconfig.json | 58 + packages/tasks/webiny.config.js | 8 + packages/utils/src/createZodError.ts | 2 +- packages/utils/src/headers.ts | 10 +- scripts/listPackagesWithTests.js | 6 + scripts/release/index.js | 6 +- typings/env/index.d.ts | 1 + yarn.lock | 1104 ++++++++++++++++- 671 files changed, 19244 insertions(+), 6683 deletions(-) delete mode 100644 apps/api/fileManager/download/package.json delete mode 100644 apps/api/fileManager/download/src/index.ts delete mode 100644 apps/api/fileManager/download/tsconfig.json delete mode 100644 apps/api/fileManager/download/webiny.config.ts delete mode 100644 apps/api/fileManager/transform/package.json delete mode 100644 apps/api/fileManager/transform/src/index.ts delete mode 100644 apps/api/fileManager/transform/tsconfig.json delete mode 100644 apps/api/fileManager/transform/webiny.config.ts create mode 100644 apps/api/graphql/src/plugins/continuingTask.ts create mode 100644 apps/api/graphql/src/plugins/countDynamoDbTask.ts create mode 100644 cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuCrud.cy.ts create mode 100644 cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuItems.cy.ts create mode 100644 cypress-tests/cypress/e2e/admin/pageBuilder/menus/pagesListMenuItemType.cy.ts delete mode 100644 cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.js create mode 100644 cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.ts create mode 100644 cypress-tests/cypress/support/pageBuilder/pbClearMainMenu.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbCreateMenu.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbCreateMenu.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbCreatePage.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbCreatePage.ts create mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeleteAllMenus.ts create mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeleteAllPages.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeletePage.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbDeletePage.ts create mode 100644 cypress-tests/cypress/support/pageBuilder/pbListMenus.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbListPages.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbListPages.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbPublishPage.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbPublishPage.ts delete mode 100644 cypress-tests/cypress/support/pageBuilder/pbUpdatePage.js create mode 100644 cypress-tests/cypress/support/pageBuilder/pbUpdatePage.ts create mode 100644 cypress-tests/cypress/support/pageBuilder/reloadUntil.ts create mode 100644 packages/api-background-tasks-ddb/.babelrc.js create mode 100644 packages/api-background-tasks-ddb/LICENSE create mode 100644 packages/api-background-tasks-ddb/README.md create mode 100644 packages/api-background-tasks-ddb/package.json create mode 100644 packages/api-background-tasks-ddb/src/index.ts create mode 100644 packages/api-background-tasks-ddb/tsconfig.build.json create mode 100644 packages/api-background-tasks-ddb/tsconfig.json create mode 100644 packages/api-background-tasks-ddb/webiny.config.js create mode 100644 packages/api-background-tasks-es/.babelrc.js create mode 100644 packages/api-background-tasks-es/LICENSE create mode 100644 packages/api-background-tasks-es/README.md create mode 100644 packages/api-background-tasks-es/package.json create mode 100644 packages/api-background-tasks-es/src/index.ts create mode 100644 packages/api-background-tasks-es/tsconfig.build.json create mode 100644 packages/api-background-tasks-es/tsconfig.json create mode 100644 packages/api-background-tasks-es/webiny.config.js create mode 100644 packages/api-background-tasks-os/.babelrc.js create mode 100644 packages/api-background-tasks-os/LICENSE create mode 100644 packages/api-background-tasks-os/README.md create mode 100644 packages/api-background-tasks-os/package.json create mode 100644 packages/api-background-tasks-os/src/index.ts create mode 100644 packages/api-background-tasks-os/tsconfig.build.json create mode 100644 packages/api-background-tasks-os/tsconfig.json create mode 100644 packages/api-background-tasks-os/webiny.config.js create mode 100644 packages/api-elasticsearch-tasks/.babelrc.js create mode 100644 packages/api-elasticsearch-tasks/LICENSE create mode 100644 packages/api-elasticsearch-tasks/README.md create mode 100644 packages/api-elasticsearch-tasks/__tests__/helpers/helpers.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/helpers/useHandler.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/context.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/elasticsearch.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/event.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/identity.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/indexManager.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/store.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/mocks/task.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/settings/indexManager.test.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexing.test.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskDefinition.test.ts create mode 100644 packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskRunner.test.ts create mode 100644 packages/api-elasticsearch-tasks/jest.setup.js create mode 100644 packages/api-elasticsearch-tasks/package.json create mode 100644 packages/api-elasticsearch-tasks/src/definitions/entry.ts create mode 100644 packages/api-elasticsearch-tasks/src/definitions/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/definitions/table.ts create mode 100644 packages/api-elasticsearch-tasks/src/errors/IndexSettingsGetError.ts create mode 100644 packages/api-elasticsearch-tasks/src/errors/IndexSettingsSetError.ts create mode 100644 packages/api-elasticsearch-tasks/src/errors/IndexingDisableError.ts create mode 100644 packages/api-elasticsearch-tasks/src/errors/IndexingEnableError.ts create mode 100644 packages/api-elasticsearch-tasks/src/errors/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/helpers/scan.ts create mode 100644 packages/api-elasticsearch-tasks/src/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/DisableIndexing.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/EnableIndexing.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/IndexManager.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/IndexSettingsManager.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/settings/types.ts create mode 100644 packages/api-elasticsearch-tasks/src/tasks/Manager.ts create mode 100644 packages/api-elasticsearch-tasks/src/tasks/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts create mode 100644 packages/api-elasticsearch-tasks/src/tasks/reindexing/index.ts create mode 100644 packages/api-elasticsearch-tasks/src/tasks/reindexing/reindexingTaskDefinition.ts create mode 100644 packages/api-elasticsearch-tasks/src/types.ts create mode 100644 packages/api-elasticsearch-tasks/tsconfig.build.json create mode 100644 packages/api-elasticsearch-tasks/tsconfig.json create mode 100644 packages/api-elasticsearch-tasks/webiny.config.js create mode 100644 packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/createAssetDelivery.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3ContentsReader.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3ErrorAssetReply.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3RedirectAssetReply.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/S3StreamAssetReply.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/transformation/AssetKeyGenerator.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/transformation/CallableContentsReader.ts create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/transformation/WidthCollection.ts rename packages/{api-file-manager/src/handlers/transform => api-file-manager-s3/src/assetDelivery/s3/transformation}/legacyUtils.ts (87%) create mode 100644 packages/api-file-manager-s3/src/assetDelivery/s3/transformation/utils.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/index.ts create mode 100644 packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts create mode 100644 packages/api-file-manager-s3/src/plugins/addFileMetadata.ts create mode 100644 packages/api-file-manager/__tests__/handlers/download.disabled.ts delete mode 100644 packages/api-file-manager/__tests__/handlers/download.test.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/AliasAssetRequestResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/AssetRequest.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/NullAssetOutputStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/NullAssetReply.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/NullAssetResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/NullRequestResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/SetCacheControlHeaders.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetContentsReader.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetOutputStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetProcessor.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetReply.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetRequestResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetTransformationStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/createAssetDeliveryPluginLoader.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedAssetReply.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedOutputStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateCache.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFileAssetRequestResolver.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PublicCache.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPrivateUrlOutputStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPublicUrlOutputStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/internalIdentity.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetProcessor.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetTransformationStrategy.ts create mode 100644 packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts create mode 100644 packages/api-file-manager/src/delivery/index.ts create mode 100644 packages/api-file-manager/src/delivery/setupAssetDelivery.ts delete mode 100644 packages/api-file-manager/src/handlers/download/byAlias.ts delete mode 100644 packages/api-file-manager/src/handlers/download/byExactKey.ts delete mode 100644 packages/api-file-manager/src/handlers/download/extractFileInformation.ts delete mode 100644 packages/api-file-manager/src/handlers/download/getS3Object.ts delete mode 100644 packages/api-file-manager/src/handlers/download/index.ts rename packages/api-file-manager/src/handlers/{transform/managers => manage}/imageManager.ts (95%) create mode 100644 packages/api-file-manager/src/handlers/manage/legacyUtils.ts rename packages/api-file-manager/src/handlers/{transform => manage}/utils.ts (100%) delete mode 100644 packages/api-file-manager/src/handlers/transform/index.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/loaders/imageLoader.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/loaders/index.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/loaders/sanitizeImageTransformations.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/managers/index.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/optimizeImage.ts delete mode 100644 packages/api-file-manager/src/handlers/transform/transformImage.ts create mode 100644 packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/jsonIndexing.ts create mode 100644 packages/api-headless-cms/src/graphqlFields/json.ts create mode 100644 packages/api-headless-cms/src/storage/index.ts create mode 100644 packages/api-headless-cms/src/storage/json.ts create mode 100644 packages/api-security/src/plugins/authenticateUsingCookie.ts create mode 100644 packages/api-security/src/plugins/secureHeaders.ts create mode 100644 packages/api-security/src/utils/IdentityValue.ts create mode 100644 packages/api/__tests__/ServiceDiscovery.test.ts create mode 100644 packages/api/__tests__/setup/setupAfterEnv.js create mode 100644 packages/api/jest-dynalite-config.js rename packages/api/{jest.config.js => jest.setup.js} (100%) create mode 100644 packages/api/src/ServiceDiscovery.ts create mode 100644 packages/app-aco/src/config/record/Action.tsx create mode 100644 packages/app-aco/src/config/record/index.ts create mode 100644 packages/app-admin/src/components/OptionsMenu/OptionsMenuLink.tsx create mode 100644 packages/app-admin/src/components/Wcp.tsx create mode 100644 packages/app-file-manager/src/components/BulkActions/ActionEdit/useActionEditWorker.ts create mode 100644 packages/app-file-manager/src/components/Table/Actions/CopyFile.tsx create mode 100644 packages/app-file-manager/src/components/Table/Actions/DeleteFile.tsx create mode 100644 packages/app-file-manager/src/components/Table/Actions/EditFile.tsx create mode 100644 packages/app-file-manager/src/components/Table/Actions/MoveFile.tsx create mode 100644 packages/app-file-manager/src/components/Table/Actions/index.ts delete mode 100644 packages/app-file-manager/src/components/Table/FolderActionDelete.tsx delete mode 100644 packages/app-file-manager/src/components/Table/FolderActionEdit.tsx delete mode 100644 packages/app-file-manager/src/components/Table/FolderActionManagePermissions.tsx delete mode 100644 packages/app-file-manager/src/components/Table/Name.tsx delete mode 100644 packages/app-file-manager/src/components/Table/RecordActionCopy.tsx delete mode 100644 packages/app-file-manager/src/components/Table/RecordActionDelete.tsx delete mode 100644 packages/app-file-manager/src/components/Table/RecordActionEdit.tsx delete mode 100644 packages/app-file-manager/src/components/Table/RecordActionMove.tsx delete mode 100644 packages/app-file-manager/src/components/Table/styled.tsx create mode 100644 packages/app-file-manager/src/components/fields/AccessControl.tsx rename packages/app-file-manager/src/components/{FileDetails/components => fields}/Aliases.tsx (100%) rename packages/app-file-manager/src/components/{FileDetails/components => fields}/Name.tsx (100%) rename packages/app-file-manager/src/components/{FileDetails/components => fields}/Tags.tsx (76%) create mode 100644 packages/app-file-manager/src/components/fields/index.ts create mode 100644 packages/app-file-manager/src/components/fields/useAccessControlField.tsx create mode 100644 packages/app-file-manager/src/components/fields/useFileOrUndefined.ts create mode 100644 packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/BulkEditField.tsx create mode 100644 packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx delete mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx create mode 100644 packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx rename packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/{RuleActionSelect.tsx => SelectRuleAction.tsx} (60%) create mode 100644 packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts create mode 100644 packages/app-form-builder/src/components/Form/functions/usePrevious.ts create mode 100644 packages/app-form-builder/src/validators/endsWith.ts create mode 100644 packages/app-form-builder/src/validators/gt.ts create mode 100644 packages/app-form-builder/src/validators/includes.ts create mode 100644 packages/app-form-builder/src/validators/index.ts create mode 100644 packages/app-form-builder/src/validators/is.ts create mode 100644 packages/app-form-builder/src/validators/lt.ts create mode 100644 packages/app-form-builder/src/validators/startsWith.ts create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/ChangeEntryStatus.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/DeleteEntry.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/EditEntry.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/MoveEntry.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/index.ts delete mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionDelete.tsx delete mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionEdit.tsx delete mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionMove.tsx delete mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionPublish.tsx create mode 100644 packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx create mode 100644 packages/app-headless-cms/src/admin/hooks/useChangeEntryStatus.tsx create mode 100644 packages/app-headless-cms/src/admin/hooks/useDeleteEntry.tsx create mode 100644 packages/app-headless-cms/src/admin/hooks/useEntry.tsx create mode 100644 packages/app-page-builder-elements/src/validators/endsWith.ts create mode 100644 packages/app-page-builder-elements/src/validators/gt.ts create mode 100644 packages/app-page-builder-elements/src/validators/includes.ts create mode 100644 packages/app-page-builder-elements/src/validators/index copy.ts create mode 100644 packages/app-page-builder-elements/src/validators/index.ts create mode 100644 packages/app-page-builder-elements/src/validators/is.ts create mode 100644 packages/app-page-builder-elements/src/validators/lt.ts create mode 100644 packages/app-page-builder-elements/src/validators/startsWith.ts create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/ChangePageStatus.tsx create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/DeletePage.tsx create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/MovePage.tsx create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/PreviewPage.tsx create mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Actions/index.ts delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Folder/FolderActionManagePermissions.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionDelete.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionEdit.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionMove.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPreview.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPublish.tsx delete mode 100644 packages/app-page-builder/src/admin/components/Table/Table/styled.tsx create mode 100644 packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx create mode 100644 packages/app-page-builder/src/admin/views/Pages/hooks/useChangePageStatus.tsx create mode 100644 packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts create mode 100644 packages/app-page-builder/src/admin/views/Pages/hooks/usePage.tsx create mode 100644 packages/app-page-builder/src/admin/views/Pages/hooks/usePreviewPage.ts create mode 100644 packages/app-page-builder/src/components.ts create mode 100644 packages/aws-layers/index.d.ts create mode 100644 packages/aws-sdk/src/client-eventbridge/types.ts create mode 100644 packages/aws-sdk/src/client-sfn/index.ts create mode 100644 packages/create-webiny-project/utils/getNpmVersion.js create mode 100644 packages/form/src/BindPrefix.tsx create mode 100644 packages/handler/__tests__/headers.test.ts create mode 100644 packages/handler/src/ResponseHeaders.ts create mode 100644 packages/handler/src/plugins/ModifyResponseHeadersPlugin.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/ddb-es/FileManager_5_39_0_005.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/ddb-es/index.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/ddb/FileManager_5_39_0_005.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/ddb/index.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/utils/FileMetadata.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/utils/createFileEntity.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/utils/createLocaleEntity.ts create mode 100644 packages/migrations/src/migrations/5.39.0/005/utils/createTenantEntity.ts create mode 100644 packages/project-utils/configs/tsconfig.build.json create mode 100644 packages/pulumi-aws/src/apps/api/ApiBackgroundTask.ts create mode 100644 packages/pulumi-aws/src/apps/api/backgroundTask/definition.ts create mode 100644 packages/pulumi-aws/src/apps/api/backgroundTask/policy.ts create mode 100644 packages/pulumi-aws/src/apps/api/backgroundTask/role.ts create mode 100644 packages/pulumi-aws/src/apps/api/backgroundTask/types.ts create mode 100644 packages/pulumi-aws/src/utils/addServiceManifestTableItem.ts create mode 100644 packages/pulumi-aws/src/utils/withServiceManifest.ts delete mode 100644 packages/serverless-cms-aws/handlers/common/api/fileManager/download/src/index.ts delete mode 100644 packages/serverless-cms-aws/handlers/common/api/fileManager/download/webiny.config.js delete mode 100644 packages/serverless-cms-aws/handlers/common/api/fileManager/transform/src/index.ts delete mode 100644 packages/serverless-cms-aws/handlers/common/api/fileManager/transform/webiny.config.js create mode 100644 packages/tasks/.babelrc.js create mode 100644 packages/tasks/LICENSE create mode 100644 packages/tasks/README.md create mode 100644 packages/tasks/__tests__/crud/definitions.test.ts create mode 100644 packages/tasks/__tests__/crud/store.test.ts create mode 100644 packages/tasks/__tests__/crud/trigger.test.ts create mode 100644 packages/tasks/__tests__/graphql/definitions.test.ts create mode 100644 packages/tasks/__tests__/graphql/logs.test.ts create mode 100644 packages/tasks/__tests__/graphql/tasks.test.ts create mode 100644 packages/tasks/__tests__/helpers/graphql/definitions.ts create mode 100644 packages/tasks/__tests__/helpers/graphql/logs.ts create mode 100644 packages/tasks/__tests__/helpers/graphql/tasks.ts create mode 100644 packages/tasks/__tests__/helpers/helpers.ts create mode 100644 packages/tasks/__tests__/helpers/tenancySecurity.ts create mode 100644 packages/tasks/__tests__/helpers/useGraphQLHandler.ts create mode 100644 packages/tasks/__tests__/helpers/useHandler.ts create mode 100644 packages/tasks/__tests__/mocks/context.ts create mode 100644 packages/tasks/__tests__/mocks/definition.ts create mode 100644 packages/tasks/__tests__/mocks/event.ts create mode 100644 packages/tasks/__tests__/mocks/identity.ts create mode 100644 packages/tasks/__tests__/mocks/index.ts create mode 100644 packages/tasks/__tests__/mocks/response.ts create mode 100644 packages/tasks/__tests__/mocks/runner.ts create mode 100644 packages/tasks/__tests__/mocks/store.ts create mode 100644 packages/tasks/__tests__/mocks/task.ts create mode 100644 packages/tasks/__tests__/mocks/taskLog.ts create mode 100644 packages/tasks/__tests__/mocks/taskResponse.ts create mode 100644 packages/tasks/__tests__/runner/taskEventValidation.test.ts create mode 100644 packages/tasks/__tests__/runner/taskManager.test.ts create mode 100644 packages/tasks/__tests__/runner/taskManagerStore.test.ts create mode 100644 packages/tasks/__tests__/task/input.test.ts create mode 100644 packages/tasks/__tests__/task/plugin.test.ts create mode 100644 packages/tasks/__tests__/types.ts create mode 100644 packages/tasks/jest.setup.js create mode 100644 packages/tasks/package.json create mode 100644 packages/tasks/src/context.ts create mode 100644 packages/tasks/src/crud/createEventBridgeEvent.ts create mode 100644 packages/tasks/src/crud/crud.tasks.ts create mode 100644 packages/tasks/src/crud/definition.tasks.ts create mode 100644 packages/tasks/src/crud/model.ts create mode 100644 packages/tasks/src/crud/trigger.tasks.ts create mode 100644 packages/tasks/src/crud/where.ts create mode 100644 packages/tasks/src/graphql/checkPermissions.ts create mode 100644 packages/tasks/src/graphql/index.ts create mode 100644 packages/tasks/src/graphql/utils.ts create mode 100644 packages/tasks/src/handler/index.ts create mode 100644 packages/tasks/src/handler/register.ts create mode 100644 packages/tasks/src/handler/types.ts create mode 100644 packages/tasks/src/index.ts create mode 100644 packages/tasks/src/response/DatabaseResponse.ts create mode 100644 packages/tasks/src/response/Response.ts create mode 100644 packages/tasks/src/response/ResponseAbortedResult.ts create mode 100644 packages/tasks/src/response/ResponseContinueResult.ts create mode 100644 packages/tasks/src/response/ResponseDoneResult.ts create mode 100644 packages/tasks/src/response/ResponseErrorResult.ts create mode 100644 packages/tasks/src/response/TaskResponse.ts create mode 100644 packages/tasks/src/response/abstractions/Response.ts create mode 100644 packages/tasks/src/response/abstractions/ResponseAbortedResult.ts create mode 100644 packages/tasks/src/response/abstractions/ResponseBaseResult.ts create mode 100644 packages/tasks/src/response/abstractions/ResponseContinueResult.ts create mode 100644 packages/tasks/src/response/abstractions/ResponseDoneResult.ts create mode 100644 packages/tasks/src/response/abstractions/ResponseErrorResult.ts create mode 100644 packages/tasks/src/response/abstractions/TaskResponse.ts create mode 100644 packages/tasks/src/response/abstractions/index.ts create mode 100644 packages/tasks/src/response/index.ts create mode 100644 packages/tasks/src/runner/TaskControl.ts create mode 100644 packages/tasks/src/runner/TaskEventValidation.ts create mode 100644 packages/tasks/src/runner/TaskManager.ts create mode 100644 packages/tasks/src/runner/TaskManagerStore.ts create mode 100644 packages/tasks/src/runner/TaskRunner.ts create mode 100644 packages/tasks/src/runner/abstractions/TaskControl.ts create mode 100644 packages/tasks/src/runner/abstractions/TaskEventValidation.ts create mode 100644 packages/tasks/src/runner/abstractions/TaskManager.ts create mode 100644 packages/tasks/src/runner/abstractions/TaskManagerStore.ts create mode 100644 packages/tasks/src/runner/abstractions/TaskRunner.ts create mode 100644 packages/tasks/src/runner/abstractions/index.ts create mode 100644 packages/tasks/src/runner/index.ts create mode 100644 packages/tasks/src/runner/utils/getErrorProperties.ts create mode 100644 packages/tasks/src/runner/utils/getObjectProperties.ts create mode 100644 packages/tasks/src/task/index.ts create mode 100644 packages/tasks/src/task/input.ts create mode 100644 packages/tasks/src/task/plugin.ts create mode 100644 packages/tasks/src/types.ts create mode 100644 packages/tasks/tsconfig.build.json create mode 100644 packages/tasks/tsconfig.json create mode 100644 packages/tasks/webiny.config.js diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index b193d937797..7573f96dafb 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -132,6 +132,8 @@ jobs: run: yarn --immutable - name: Build packages (full) run: yarn build + - name: Check types for Cypress tests + run: yarn cy:ts env: NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false diff --git a/.github/workflows/wac/pullRequests.wac.ts b/.github/workflows/wac/pullRequests.wac.ts index d7edfd3cb45..bc2355d29af 100644 --- a/.github/workflows/wac/pullRequests.wac.ts +++ b/.github/workflows/wac/pullRequests.wac.ts @@ -211,6 +211,10 @@ export const pullRequests = createWorkflow({ { name: "Build packages (full)", run: "yarn build" + }, + { + name: "Check types for Cypress tests", + run: "yarn cy:ts" } ] }), diff --git a/.gitignore b/.gitignore index 2ca175a0a88..edbd1daf085 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ lerna.json # TODO remove after moving traffic splitting config to WPC gateway.*.json + +packages/tasks/tpl/* diff --git a/.prettierignore b/.prettierignore index 38fd3b5281e..159465061cf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ .webiny/** packages/ui/src/RichTextEditor/editorjs/** lerna.json +coverage/** diff --git a/apps/api/fileManager/download/package.json b/apps/api/fileManager/download/package.json deleted file mode 100644 index f3a33907936..00000000000 --- a/apps/api/fileManager/download/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "api-file-manager-download", - "version": "0.1.0", - "scripts": { - "build": "yarn webiny run build", - "watch": "yarn webiny run watch" - }, - "dependencies": { - "@webiny/api-file-manager": "0.0.0", - "@webiny/aws-sdk": "0.0.0", - "@webiny/cli": "0.0.0", - "@webiny/handler-aws": "0.0.0", - "@webiny/project-utils": "0.0.0" - } -} diff --git a/apps/api/fileManager/download/src/index.ts b/apps/api/fileManager/download/src/index.ts deleted file mode 100644 index 08abb650e85..00000000000 --- a/apps/api/fileManager/download/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; -import { createHandler } from "@webiny/handler-aws"; -import { - createDownloadFileByAliasPlugins, - createDownloadFileByExactKeyPlugins -} from "@webiny/api-file-manager/handlers/download"; - -const documentClient = getDocumentClient(); - -export const handler = createHandler({ - plugins: [ - createDownloadFileByExactKeyPlugins(), - createDownloadFileByAliasPlugins({ documentClient }) - ] -}); diff --git a/apps/api/fileManager/download/tsconfig.json b/apps/api/fileManager/download/tsconfig.json deleted file mode 100644 index 1c2d81a207e..00000000000 --- a/apps/api/fileManager/download/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../../tsconfig.json", - "include": ["src"], - "references": [ - { "path": "../../../../packages/api-file-manager/tsconfig.build.json" }, - { "path": "../../../../packages/aws-sdk/tsconfig.build.json" }, - { "path": "../../../../packages/handler-aws/tsconfig.build.json" } - ], - "compilerOptions": { - "paths": { - "~/*": ["./src/*"], - "@webiny/api-file-manager/*": ["../../../../packages/api-file-manager/src/*"], - "@webiny/api-file-manager": ["../../../../packages/api-file-manager/src"], - "@webiny/aws-sdk/*": ["../../../../packages/aws-sdk/src/*"], - "@webiny/aws-sdk": ["../../../../packages/aws-sdk/src"], - "@webiny/handler-aws/*": ["../../../../packages/handler-aws/src/*"], - "@webiny/handler-aws": ["../../../../packages/handler-aws/src"] - }, - "baseUrl": "." - } -} diff --git a/apps/api/fileManager/download/webiny.config.ts b/apps/api/fileManager/download/webiny.config.ts deleted file mode 100644 index af49be8a390..00000000000 --- a/apps/api/fileManager/download/webiny.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createBuildFunction, createWatchFunction } from "@webiny/project-utils"; - -export default { - commands: { - build: createBuildFunction({ cwd: __dirname }), - watch: createWatchFunction({ cwd: __dirname }) - } -}; diff --git a/apps/api/fileManager/transform/package.json b/apps/api/fileManager/transform/package.json deleted file mode 100644 index e3293c3c21b..00000000000 --- a/apps/api/fileManager/transform/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "api-file-manager-transform", - "version": "0.1.0", - "scripts": { - "build": "yarn webiny run build", - "watch": "yarn webiny run watch" - }, - "dependencies": { - "@webiny/api-file-manager": "0.0.0", - "@webiny/cli": "0.0.0", - "@webiny/handler-aws": "0.0.0", - "@webiny/project-utils": "0.0.0" - } -} diff --git a/apps/api/fileManager/transform/src/index.ts b/apps/api/fileManager/transform/src/index.ts deleted file mode 100644 index 950b6c9daaf..00000000000 --- a/apps/api/fileManager/transform/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createHandler } from "@webiny/handler-aws/raw"; -import { createTransformFilePlugins } from "@webiny/api-file-manager/handlers/transform"; - -export const handler = createHandler({ - plugins: [createTransformFilePlugins()] -}); diff --git a/apps/api/fileManager/transform/tsconfig.json b/apps/api/fileManager/transform/tsconfig.json deleted file mode 100644 index 253c088830a..00000000000 --- a/apps/api/fileManager/transform/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../../tsconfig.json", - "include": ["src"], - "references": [ - { "path": "../../../../packages/api-file-manager/tsconfig.build.json" }, - { "path": "../../../../packages/handler-aws/tsconfig.build.json" } - ], - "compilerOptions": { - "paths": { - "~/*": ["./src/*"], - "@webiny/api-file-manager/*": ["../../../../packages/api-file-manager/src/*"], - "@webiny/api-file-manager": ["../../../../packages/api-file-manager/src"], - "@webiny/handler-aws/*": ["../../../../packages/handler-aws/src/*"], - "@webiny/handler-aws": ["../../../../packages/handler-aws/src"] - }, - "baseUrl": "." - } -} diff --git a/apps/api/fileManager/transform/webiny.config.ts b/apps/api/fileManager/transform/webiny.config.ts deleted file mode 100644 index fae3a6c5265..00000000000 --- a/apps/api/fileManager/transform/webiny.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createBuildFunction, createWatchFunction } from "@webiny/project-utils"; - -const webpack = config => { - (config.externals as any).push("sharp"); - return config; -}; - -export default { - commands: { - build: createBuildFunction({ cwd: __dirname, overrides: { webpack } }), - watch: createWatchFunction({ cwd: __dirname, overrides: { webpack } }) - } -}; diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index 2114ae0eeee..928de2b1aae 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -12,6 +12,7 @@ "@webiny/api-apw": "0.0.0", "@webiny/api-apw-scheduler-so-ddb": "0.0.0", "@webiny/api-audit-logs": "0.0.0", + "@webiny/api-background-tasks-ddb": "0.0.0", "@webiny/api-file-manager": "0.0.0", "@webiny/api-file-manager-ddb": "0.0.0", "@webiny/api-file-manager-s3": "0.0.0", @@ -43,7 +44,8 @@ "@webiny/handler-db": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/handler-logs": "0.0.0", - "@webiny/project-utils": "0.0.0" + "@webiny/project-utils": "0.0.0", + "@webiny/tasks": "0.0.0" }, "devDependencies": { "@webiny/cli-plugin-deploy-pulumi": "0.0.0", diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index 381a073c181..87ad1dd0cfc 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -23,7 +23,7 @@ import { } from "@webiny/api-file-manager"; import { createFileManagerStorageOperations } from "@webiny/api-file-manager-ddb"; import logsPlugins from "@webiny/handler-logs"; -import fileManagerS3 from "@webiny/api-file-manager-s3"; +import fileManagerS3, { createAssetDelivery } from "@webiny/api-file-manager-s3"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; @@ -35,10 +35,11 @@ import { createStorageOperations as createApwSaStorageOperations } from "@webiny import { createAco } from "@webiny/api-aco"; import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import { createAuditLogs } from "@webiny/api-audit-logs"; - -// Imports plugins created via scaffolding utilities. +import { createBackgroundTasks } from "@webiny/api-background-tasks-ddb"; import scaffoldsPlugins from "./plugins/scaffolds"; import { createBenchmarkEnablePlugin } from "~/plugins/benchmarkEnable"; +import { createCountDynamoDbTask } from "~/plugins/countDynamoDbTask"; +import { createContinuingTask } from "~/plugins/continuingTask"; const debug = process.env.DEBUG === "true"; const documentClient = getDocumentClient(); @@ -65,12 +66,14 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ documentClient }) }), createFileManagerGraphQL(), + createAssetDelivery({ documentClient }), fileManagerS3(), prerenderingServicePlugins({ eventBus: String(process.env.EVENT_BUS) @@ -119,7 +122,9 @@ export const handler = createHandler({ } }); }), - createAuditLogs() + createAuditLogs(), + createCountDynamoDbTask(), + createContinuingTask() ], debug }); diff --git a/apps/api/graphql/src/plugins/continuingTask.ts b/apps/api/graphql/src/plugins/continuingTask.ts new file mode 100644 index 00000000000..8a09a021bbf --- /dev/null +++ b/apps/api/graphql/src/plugins/continuingTask.ts @@ -0,0 +1,58 @@ +import { createTaskDefinition } from "@webiny/tasks"; +import { Context } from "../types"; +import { ITaskResponseContinueOptions } from "@webiny/tasks/response/abstractions"; + +const MAX_RUNS = 5; + +const getMaxRuns = (input?: string | number) => { + const value = Number(input); + if (isNaN(value)) { + return MAX_RUNS; + } + return value > 0 && value < 50 ? value : MAX_RUNS; +}; + +interface TaskValues { + run?: number; + maxRuns?: string | number; + seconds?: number | string; +} + +const getOptions = (values: TaskValues): ITaskResponseContinueOptions | undefined => { + if (!values.seconds || typeof values.seconds !== "number" || values.seconds < 1) { + return undefined; + } + return { + seconds: values.seconds + }; +}; + +export const createContinuingTask = () => { + return createTaskDefinition({ + id: "continuingTask", + title: "Mock Continuing Task", + description: + "This is a mock task which will continue to run until it reaches the defined run limit.", + async run(params) { + const { response, isAborted, input } = params; + const run = input.run || 0; + const maxRuns = getMaxRuns(input.maxRuns); + if (run >= maxRuns) { + return response.done("Got to the run limit."); + } + if (isAborted()) { + return response.aborted(); + } + + await new Promise(resolve => setTimeout(resolve, 10000)); + + return response.continue( + { + ...(input || {}), + run: run + 1 + }, + getOptions(input) + ); + } + }); +}; diff --git a/apps/api/graphql/src/plugins/countDynamoDbTask.ts b/apps/api/graphql/src/plugins/countDynamoDbTask.ts new file mode 100644 index 00000000000..310aafc01ed --- /dev/null +++ b/apps/api/graphql/src/plugins/countDynamoDbTask.ts @@ -0,0 +1,25 @@ +import { createTaskDefinition } from "@webiny/tasks"; +import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; + +export const createCountDynamoDbTask = () => { + return createTaskDefinition({ + id: "countDdb", + title: "Count DynamoDB", + description: "Counts DynamoDB items.", + run: async params => { + const { response, isAborted, isCloseToTimeout } = params; + if (isAborted()) { + return response.aborted(); + } else if (isCloseToTimeout()) { + return response.continue({}); + } + const documentClient = getDocumentClient(); + + const results = await documentClient.scan({ + TableName: process.env.DB_TABLE + }); + + return response.done(`Count: ${results.Count}`); + } + }); +}; diff --git a/apps/api/graphql/src/types.ts b/apps/api/graphql/src/types.ts index ba39387dad7..e4d9455dbde 100644 --- a/apps/api/graphql/src/types.ts +++ b/apps/api/graphql/src/types.ts @@ -7,6 +7,7 @@ import { FormBuilderContext } from "@webiny/api-form-builder/types"; import { CmsContext } from "@webiny/api-headless-cms/types"; import { AcoContext } from "@webiny/api-aco/types"; import { PbAcoContext } from "@webiny/api-page-builder-aco/types"; +import { Context as TasksContext } from "@webiny/tasks/types"; // When working with the `context` object (for example while defining a new GraphQL resolver function), // you can import this interface and assign it to it. This will give you full autocomplete functionality @@ -23,4 +24,5 @@ export interface Context CmsContext, FormBuilderContext, AcoContext, + TasksContext, PbAcoContext {} diff --git a/apps/api/graphql/tsconfig.json b/apps/api/graphql/tsconfig.json index bb36e7f8384..24dd0dfe1b5 100644 --- a/apps/api/graphql/tsconfig.json +++ b/apps/api/graphql/tsconfig.json @@ -112,6 +112,12 @@ }, { "path": "../../../packages/api-apw-scheduler-so-ddb" + }, + { + "path": "../../../packages/api-background-tasks-ddb/tsconfig.build.json" + }, + { + "path": "../../../packages/tasks/tsconfig.build.json" } ], "compilerOptions": { @@ -188,7 +194,11 @@ "@webiny/handler-graphql/*": ["../../../packages/handler-graphql/src/*"], "@webiny/handler-graphql": ["../../../packages/handler-graphql/src"], "@webiny/handler-logs/*": ["../../../packages/handler-logs/src/*"], - "@webiny/handler-logs": ["../../../packages/handler-logs/src"] + "@webiny/handler-logs": ["../../../packages/handler-logs/src"], + "@webiny/api-background-tasks-ddb/*": ["../../../packages/api-background-tasks-ddb/src/*"], + "@webiny/api-background-tasks-ddb": ["../../../packages/api-background-tasks-ddb/src"], + "@webiny/tasks/*": ["../../../packages/tasks/src/*"], + "@webiny/tasks": ["../../../packages/tasks/src"] }, "baseUrl": "." } diff --git a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuCrud.cy.ts b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuCrud.cy.ts new file mode 100644 index 00000000000..7920a8dea7f --- /dev/null +++ b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuCrud.cy.ts @@ -0,0 +1,59 @@ +import { customAlphabet } from "nanoid"; + +context("Page Builder - Menu CRUD", () => { + const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz"); + const menuName = nanoid(10); + const menuSlug = nanoid(10); + + const menuNameEdit = "Testing Menu123"; + const menuDescEdit = "This is an edited description."; + + beforeEach(() => { + cy.login(); + cy.pbDeleteAllMenus(); + }); + + it("should be able to create, edit, and immediately delete a menu", () => { + cy.visit("/page-builder/menus"); + + // Create a menu. + cy.findByTestId("data-list-new-record-button").should("exist"); + cy.findByTestId("data-list-new-record-button").click(); + + cy.findByTestId("pb.menu.create.name").type(menuName); + cy.findByTestId("pb.menu.save.button").click(); + cy.findByText("Value is required.").should("exist"); + cy.findByTestId("pb.menu.create.slug").type(menuSlug); + cy.findByTestId("pb.menu.create.description").type("This is a cool test."); + cy.findByTestId("pb.menu.save.button").click(); + + cy.findByText("Menu saved successfully.").should("exist"); + + // Edit the menu. + cy.findByTestId("pb.menu.create.name").clear().type(menuNameEdit); + cy.findByTestId("pb.menu.create.slug").should("be.disabled"); + cy.findByTestId("pb.menu.create.description").clear().type(menuDescEdit); + cy.findByTestId("pb.menu.save.button").click(); + cy.findByText("Menu saved successfully.").should("exist"); + + // Assert the menu is being displayed properly on the right side of the screen. + cy.findByTestId("default-data-list").within(() => { + cy.get("li") + .first() + .within(() => { + cy.findByText(menuNameEdit).should("exist"); + cy.findByText(menuDescEdit).should("exist"); + cy.findByTestId("pb-menus-list-delete-menu-btn").click({ force: true }); + }); + }); + + // Finish deleting the menu and assert it is deleted. + cy.contains("Are you sure you want to continue?").should("exist"); + cy.findAllByTestId("confirmationdialog-confirm-action").click(); + + cy.findByText(`Menu "${menuSlug}" deleted.`).should("exist"); + cy.findByTestId("default-data-list").within(() => { + cy.findByText(menuNameEdit).should("not.exist"); + }); + }); +}); diff --git a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuItems.cy.ts b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuItems.cy.ts new file mode 100644 index 00000000000..2bd8a3411b0 --- /dev/null +++ b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/menuItems.cy.ts @@ -0,0 +1,208 @@ +import { customAlphabet } from "nanoid"; + +context("Page Builder - Menu Items", () => { + const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz"); + + const pageListName = nanoid(10); + const pageListNameEdit = pageListName + "-edit"; + + const linkName = nanoid(10); + const linkURL = `/${linkName}/`; + const linkNameEdit = linkName + "-edit"; + const linkURLEdit = `/${linkNameEdit}/`; + + const folderName = nanoid(10); + const folderNameEdit = folderName + "-edit"; + const pageNameNew = nanoid(10); + const pageNameNewEdit = pageNameNew + "-edit"; + + const menuData = { + data: { + title: "Testing menu items", + slug: "test-menu-items", + description: "Testing menu items.", + items: [] + } + }; + + beforeEach(() => { + cy.login(); + cy.pbDeleteAllMenus(); + cy.pbDeleteAllPages(); + cy.pbCreateMenu(menuData); + cy.pbCreatePage({ category: "static" }).then(page => { + cy.pbUpdatePage({ + id: page.id, + data: { + category: "static", + path: `/${pageNameNew}`, + title: pageNameNew, + settings: { + general: { + layout: "static", + tags: [pageNameNew, pageNameNew] + } + } + } + }).then(page => { + cy.pbPublishPage({ id: page.data.id }); + }); + }); + + cy.pbCreatePage({ category: "static" }).then(page => { + cy.pbUpdatePage({ + id: page.id, + data: { + category: "static", + path: `/${pageNameNewEdit}`, + title: pageNameNewEdit, + settings: { + general: { + layout: "static", + tags: [pageNameNewEdit, pageNameNewEdit] + } + } + } + }).then(page => { + cy.pbPublishPage({ id: page.data.id }); + }); + }); + }); + + it("should be able to create, edit, and immediately delete all menu items within a menu", () => { + cy.visit("/page-builder/menus"); + cy.findByTestId("default-data-list").within(() => { + cy.get("li").first().click(); + }); + + // Create page list menu items. + cy.findByTestId("pb.menu.create.items.button").children("button").click(); + cy.findByTestId("pb.menu.create.items.button").within(() => { + cy.findByText("Page list").click(); + }); + cy.findByTestId("pb.menu.new.listitem.title").type(pageListName); + cy.findByTestId("pb.menu.new.listitem.button.save").click().wait(200); + cy.findByText("Value is required.").should("exist"); + cy.findByTestId("pb.menu.new.listitem.category").type(`Static`); + cy.findByText("Static").click(); + cy.findByTestId("pb.menu.new.listitem.sortby").select("Title"); + cy.findByTestId("pb.menu.new.listitem.sortdirection").select("Descending"); + cy.findByTestId("pb.menu.new.listitem.tags").type("a"); + cy.findByText("a").click(); + cy.findByTestId("pb.menu.new.listitem.tags").type("b"); + cy.findByText("b").click(); + + // Quick patch: had to add wait because clicking to fast would cause a JS error for some reason. + cy.wait(500); + + cy.findByTestId("pb.menu.new.listitem.button.save").click(); + + // Edit previously created page list items. + cy.findByTestId("pb-edit-icon-button").click(); + cy.findByTestId("pb.menu.new.listitem.title").clear().type(pageListNameEdit); + cy.findByTestId("pb.menu.new.listitem.sortby").select("Published on"); + cy.findByTestId("pb.menu.new.listitem.sortdirection").select("Ascending"); + cy.findByTestId("pb.menu.new.listitem.tags").type("c"); + cy.findByText("c").click(); + + // Quick patch: had to add wait because clicking to fast would cause a JS error for some reason. + cy.wait(500); + + cy.findByTestId("pb.menu.new.listitem.button.save").click(); + + // Assert all edits are being properly displayed. + cy.findByTestId(`pb-menu-item-render-${pageListNameEdit}`) + .contains(pageListNameEdit) + .should("exist"); + cy.findByTestId("pb-edit-icon-button").click(); + cy.findByTestId("pb.menu.new.listitem.title").should("have.value", pageListNameEdit); + cy.findByTestId("pb.menu.new.listitem.sortby").should("have.value", "publishedOn"); + cy.findByTestId("pb.menu.new.listitem.sortdirection").should("have.value", "asc"); + cy.findByTestId("pb.menu.new.listitem.button.save").click(); + + // Delete the previously created menu item. + cy.findByTestId("pb-delete-icon-button").click(); + cy.wait(500); + cy.findByTestId(`pb-menu-item-render-${pageListNameEdit}`).should("not.exist"); + + // Create link menu item. + cy.findByTestId("pb.menu.create.items.button").children("button").click(); + cy.findByTestId("pb.menu.create.items.button").within(() => { + cy.findByText("Link").click(); + }); + cy.findByTestId("pb.menu.new.link.title").type(linkName); + cy.findByTestId("pb.menu.new.link.url").type(linkURL); + cy.findByTestId("pb.menu.new.link.button.save").click(); + + // Edit the link menu item and assert everything is properly displayed. + cy.findByTestId(`pb-menu-item-render-${linkName}`).contains(linkName).should("exist"); + cy.findByTestId("pb-edit-icon-button").click(); + cy.findByTestId("pb.menu.new.link.title").should("have.value", linkName); + cy.findByTestId("pb.menu.new.link.url").should("have.value", linkURL); + cy.findByTestId("pb.menu.new.link.title").clear().type(linkNameEdit); + cy.findByTestId("pb.menu.new.link.url").clear().type(linkURLEdit); + cy.findByTestId("pb.menu.new.link.button.save").click(); + cy.findByTestId(`pb-menu-item-render-${linkNameEdit}`) + .contains(linkNameEdit) + .should("exist"); + + // Delete the link menu item and assert it's no longer being displayed. + cy.findByTestId("pb-delete-icon-button").click(); + cy.findByTestId(`pb-menu-item-render-${linkNameEdit}`).should("not.exist"); + + // Create folder menu item. + cy.findByTestId("pb.menu.create.items.button").children("button").click(); + cy.findByTestId("pb.menu.create.items.button").within(() => { + cy.findByText("Folder").click(); + }); + cy.findByTestId("pb.menu.new.folder.title").type(folderName); + cy.findByTestId("pb.menu.new.folder.button.save").click(); + cy.findByTestId(`pb-menu-item-render-${folderName}`).contains(folderName).should("exist"); + + // Edit folder menu item and assert the changes have been made. + cy.findByTestId("pb-edit-icon-button").click(); + cy.findByTestId("pb.menu.new.folder.title").should("have.value", folderName); + cy.findByTestId("pb.menu.new.folder.title").clear().type(folderNameEdit); + cy.findByTestId("pb.menu.new.folder.button.save").click(); + + cy.findByTestId(`pb-menu-item-render-${folderNameEdit}`) + .contains(folderNameEdit) + .should("exist"); + + // Delete folder menu item and assert it's no longer being displayed. + cy.findByTestId("pb-delete-icon-button").click(); + cy.findByTestId(`pb-menu-item-render-${folderNameEdit}`).should("not.exist"); + + // Create page menu item. + cy.findByTestId("pb.menu.create.items.button").children("button").click(); + cy.findByTestId("pb.menu.create.items.button").within(() => { + cy.findByText("Page").click(); + }); + cy.findByTestId("pb.menu.new.pageitem.page").type(pageNameNew); + + // Quick patch: had to add wait because clicking to fast would cause a JS error for some reason. + cy.wait(500); + + cy.get('div[role="combobox"] [role="listbox"] [role="option"]').first().click(); + cy.findByTestId("pb.menu.new.pageitem.button.save").click(); + cy.findByTestId(`pb-menu-item-render-${pageNameNewEdit}`) + .contains(pageNameNew) + .should("exist"); + + // Edit folder menu item and assert the changes have been made. + cy.findByTestId("pb-edit-icon-button").click(); + cy.findByTestId("pb.menu.new.pageitem.page").clear(); + cy.findByTestId("pb.menu.new.pageitem.title").clear({ force: true }); + cy.findByTestId("pb.menu.new.pageitem.page").clear().type(pageNameNewEdit); + cy.get('div[role="combobox"] [role="listbox"] [role="option"]').first().click(); + + cy.findByTestId("pb.menu.new.pageitem.button.save").click(); + cy.findByTestId(`pb-menu-item-render-${pageNameNewEdit}`) + .contains(pageNameNewEdit) + .should("exist"); + + // Delete folder menu item and assert it's no longer being displayed. + cy.findByTestId("pb-delete-icon-button").click(); + cy.findByTestId(`pb-menu-item-render-${pageNameNewEdit}`).should("not.exist"); + }); +}); diff --git a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/pagesListMenuItemType.cy.ts b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/pagesListMenuItemType.cy.ts new file mode 100644 index 00000000000..48e3d06d3fe --- /dev/null +++ b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/pagesListMenuItemType.cy.ts @@ -0,0 +1,172 @@ +describe("Page Builder - List Menu Item Types", () => { + const id = 4; + const idEdited = `X-${id}-Y`; + const totalPages = 3; + beforeEach(() => { + cy.login(); + }); + + it(`Step 0: create and publish ${totalPages} pages (pseudo "beforeAll" hook)`, () => { + for (let i = 0; i < totalPages; i++) { + // eslint-disable-next-line + cy.pbCreatePage({ category: "static" }).then(page => { + // eslint-disable-next-line jest/valid-expect-in-promise + cy.pbUpdatePage({ + id: page.id, + data: { + category: "static", + path: `/page-${id}-${i}`, + title: `Page-${id}-${i}`, + settings: { + general: { + layout: "static", + tags: [`page-${id}`, `page-${id}-${i}`] + } + } + } + }).then(page => { + cy.pbPublishPage({ id: page.data.id }); + }); + }); + } + }); + + it(`Step 1: create a pages list menu item in the "Main Menu" menu`, () => { + cy.pbClearMainMenu(); + + cy.visit("/page-builder/menus"); + + cy.findByTestId("default-data-list").within(() => { + cy.contains("Main Menu").click(); + }); + + // Test "Page List". + cy.findByTestId("pb.menu.create.items.button").children("button").click(); + cy.findByTestId("pb.menu.create.items.button").within(() => { + cy.findByText("Page list").click(); + }); + cy.findByTestId("pb.page.list.menu.item.form").within(() => { + cy.findByTestId("pb.menu.new.listitem.title.grid").within(() => { + cy.findByTestId("pb.menu.new.listitem.title").type(`added-menu-${id}`); + }); + }); + cy.findByTestId("pb.menu.new.listitem.button.save").click().wait(200); + cy.findByText("Value is required.").should("exist"); + cy.findByTestId("pb.menu.new.listitem.category").type(`Static`); + cy.get("[role='listbox'] > [role='option']").click(); + + cy.findByTestId("pb.menu.new.listitem.sortby").select("Title"); + cy.findByTestId("pb.menu.new.listitem.sortdirection").select("Descending"); + + cy.findByTestId("pb.menu.new.listitem.tags").type(`page-${id}-0`); + cy.findByText(`page-${id}-0`).click(); + + cy.findByTestId("pb.menu.new.listitem.tags").type(`page-${id}-1`); + cy.findByText(`page-${id}-1`).click(); + + cy.findByTestId("pb.menu.new.listitem.tags").type(`page-${id}-`); + + cy.get("[role='listbox'] > [role='option']").first().click(); + + cy.findByTestId("pb.menu.new.listitem.tags") + .type(`page-${id}-`) + .type("{downArrow}") + .click(); + + cy.findByTestId("pb.menu.new.listitem.tags") + .type(`some-custom-tag`) + .type("{downArrow}") + .click(); + cy.findByTestId("pb.menu.new.listitem.tagsrule").select("Must include any of the tags"); + + cy.findByTestId("pb.menu.new.listitem.button.save").click(); + cy.findByTestId("pb.menu.save.button").click(); + cy.findByText("Menu saved successfully."); + }); + it(`Step 2: assert that menu item and pages are shown (descending order)`, () => { + cy.visit(Cypress.env("WEBSITE_URL") + `/page-${id}-${0}/`); + + cy.reloadUntil(() => { + // We wait until the document contains the newly added menu. + return Cypress.$(`:contains(added-menu-${id})`).length > 0; + }); + + cy.findByTestId("pb-desktop-header").within(() => { + // Let's check the links and the order. + cy.findByText(`added-menu-${id}`).within(() => { + cy.get("ul li:nth-child(1)").contains(`Page-${id}-1`); + cy.get("ul li:nth-child(2)").contains(`Page-${id}-0`); + }); + }); + }); + + it(`Step 3: change the order of pages`, () => { + cy.visit("/page-builder/menus"); + + cy.findByTestId("default-data-list").within(() => { + cy.contains(`Main Menu`).click({ force: true }); + }); + + cy.findByTestId(`pb-menu-item-render-added-menu-${id}`) + .eq(0) + .within(() => { + cy.findByTestId("pb-edit-icon-button").click(); + }); + + cy.findByTestId("pb.menu.new.listitem.sortdirection").select("Ascending"); + cy.findByTestId("pb.menu.new.listitem.title").clear().type(`added-menu-${idEdited}`); + + cy.findByTestId("pb.menu.new.listitem.button.save").click(); + cy.findByTestId("pb.menu.save.button").click(); + cy.findByText("Menu saved successfully.").should("exist"); + }); + + it(`Step 4: assert that menu item and pages are shown (ascending order)`, () => { + cy.visit(Cypress.env("WEBSITE_URL") + `/page-${id}-${0}/`); + + cy.reloadUntil(() => { + // We wait until the document contains the newly added menu. + return Cypress.$(`:contains(added-menu-${idEdited})`).length > 0; + }); + + cy.findByTestId("pb-desktop-header").within(() => { + // Let's check the links and the order. + cy.findByText(`added-menu-${idEdited}`).within(() => { + cy.get("ul li:nth-child(1)").contains(`Page-${id}-0`); + cy.get("ul li:nth-child(2)").contains(`Page-${id}-1`); + }); + }); + }); + + it(`Step 5: delete the newly added pages list menu item`, () => { + cy.visit("/page-builder/menus"); + + cy.findByTestId("default-data-list").within(() => { + cy.contains(`Main Menu`).click({ force: true }); + }); + + cy.findByTestId(`pb-menu-item-render-added-menu-${idEdited}`).within(() => { + cy.findByTestId("pb-delete-icon-button").click(); + }); + + cy.findByTestId("pb.menu.save.button").click(); + cy.findByText("Menu saved successfully.").should("exist"); + }); + + it(`Step 6: assert that the pages list menu item does not exist`, () => { + cy.visit(Cypress.env("WEBSITE_URL") + `/page-${id}-${0}/`); + + cy.reloadUntil(() => { + // We wait until the document contains the newly added menu. + return Cypress.$(`:contains(added-menu-${idEdited})`).length === 0; + }); + + cy.findByTestId("pb-desktop-header").within(() => { + // Let's check the links and the order. + cy.findByText(`added-menu-${idEdited}`).should("not.exist"); + }); + }); + it(`Step 7: delete all ${totalPages} pages (pseudo "afterAll" hook)`, () => { + cy.pbDeleteAllPages(); + }); +}); diff --git a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.js b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.js deleted file mode 100644 index 052b135fed8..00000000000 --- a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.js +++ /dev/null @@ -1,135 +0,0 @@ -import uniqid from "uniqid"; - -const sort = { - NEWEST_TO_OLDEST: "createdOn_DESC", - OLDEST_TO_NEWEST: "createdOn_ASC", - A_TO_Z: "title_ASC", - Z_TO_A: "title_DESC" -}; - -context("Menus Module", () => { - const entries = []; - - before(() => { - for (let i = 0; i < 3; i++) { - cy.pbCreateMenu({ - data: { - title: uniqid(`${i}-`, "-menu-title"), - slug: uniqid("-menu-slug"), - description: uniqid("Menu-description-") - } - }).then(menu => { - entries.push(menu); - }); - } - }); - - beforeEach(() => cy.login()); - - after(() => { - for (let i = 0; i < entries.length; i++) { - const { slug } = entries[i]; - cy.pbDeleteMenu({ - slug - }); - } - }); - - it("should be able to search menu", () => { - cy.visit(`/page-builder/menus`); - - // Searching for a non existing entry should result in "no records found" - cy.findByTestId("default-data-list.search").within(() => { - cy.findByPlaceholderText(/Search menus/i).type("NON_EXISTING_ENTRY"); - }); - cy.findByTestId("ui.list.data-list").within(() => { - cy.findByText(/no records found./i).should("exist"); - }); - - // Searching for a particular entry by "title" - cy.findByTestId("default-data-list.search").within(() => { - cy.findByPlaceholderText(/Search menus/i) - .clear() - .type(entries[0].title); - }); - cy.findByTestId("ui.list.data-list").within(() => { - cy.findByText(entries[0].title).should("exist"); - }); - - // Searching for a particular entry by "description" - cy.findByTestId("default-data-list.search").within(() => { - cy.findByPlaceholderText(/Search menus/i) - .clear() - .type(entries[0].description); - }); - cy.findByTestId("ui.list.data-list").within(() => { - cy.findByText(entries[0].description).should("exist"); - }); - }); - - it("should be able to sort menus", () => { - cy.visit(`/page-builder/menus`); - - // Sort entries by "Title A -> Z" - cy.findByTestId("default-data-list.filter").click(); - cy.findByTestId("ui.list.data-list").within(() => { - cy.get("select").select(sort.A_TO_Z); - cy.findByTestId("default-data-list.filter").click(); - }); - - cy.findByTestId("default-data-list").within(() => { - cy.get(".mdc-list-item") - .first() - .within(() => { - cy.findByText(entries[0].title).should("exist"); - }); - }); - - // Sort entries by "Title Z -> A" - cy.findByTestId("default-data-list.filter").click(); - cy.findByTestId("ui.list.data-list").within(() => { - cy.get("select").select(sort.Z_TO_A); - cy.findByTestId("default-data-list.filter").click(); - }); - // We're testing it against the second element because the first one will be "Main Menu" - cy.findByTestId("default-data-list").within(() => { - cy.get(".mdc-list-item") - .first() - .next() - .within(() => { - cy.findByText(entries[entries.length - 1].title).should("exist"); - }); - }); - - // Sort entries by "Oldest to Newest" - cy.findByTestId("default-data-list.filter").click(); - cy.findByTestId("ui.list.data-list").within(() => { - cy.get("select").select(sort.OLDEST_TO_NEWEST); - cy.findByTestId("default-data-list.filter").click(); - }); - // We're testing it against the second element because the first one will be "Main Menu" - cy.findByTestId("default-data-list").within(() => { - cy.get(".mdc-list-item") - .first() - .next() - .within(() => { - cy.findByText(entries[0].title).should("exist"); - }); - }); - - // Sort entries by "Newest to Oldest" - cy.findByTestId("default-data-list.filter").click(); - cy.findByTestId("ui.list.data-list").within(() => { - cy.get("select").select(sort.NEWEST_TO_OLDEST); - cy.findByTestId("default-data-list.filter").click(); - }); - - cy.findByTestId("default-data-list").within(() => { - cy.get(".mdc-list-item") - .first() - .within(() => { - cy.findByText(entries[entries.length - 1].title).should("exist"); - }); - }); - }); -}); diff --git a/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.ts b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.ts new file mode 100644 index 00000000000..fd605746ca4 --- /dev/null +++ b/cypress-tests/cypress/e2e/admin/pageBuilder/menus/searchAndSortMenus.cy.ts @@ -0,0 +1,145 @@ +context("Page Builder - Menu Search&Sort", () => { + const menuData1 = { + data: { + title: "ABC", + slug: "abc", + description: "abc", + items: [] + } + }; + const menuData2 = { + data: { + title: "DEF", + slug: "def", + description: "def", + items: [] + } + }; + const menuData3 = { + data: { + title: "GHI", + slug: "ghi", + description: "ghi", + items: [] + } + }; + const menuData4 = { + data: { + title: "!#$%&/()=", + slug: "extra", + description: "!#$%&/()=", + items: [] + } + }; + beforeEach(() => { + cy.login(); + cy.pbDeleteAllMenus(); + cy.pbCreateMenu(menuData4); + cy.pbCreateMenu(menuData2); + cy.pbCreateMenu(menuData3); + cy.pbCreateMenu(menuData1); + }); + + it("Should be able to search and sort through the menus.", () => { + cy.visit("/page-builder/menus"); + // Using the search filter assert all the options are being correctly displayed. + cy.findByPlaceholderText("Search menus").should("exist"); + cy.contains(menuData1.data.title).should("exist"); + cy.contains(menuData2.data.title).should("exist"); + cy.contains(menuData3.data.title).should("exist"); + cy.contains(menuData4.data.title).should("exist"); + + cy.findByPlaceholderText("Search menus").clear().type(menuData1.data.title); + cy.contains(menuData1.data.title).should("exist"); + cy.contains(menuData2.data.title).should("not.exist"); + cy.contains(menuData3.data.title).should("not.exist"); + cy.contains(menuData4.data.title).should("not.exist"); + + cy.findByPlaceholderText("Search menus").clear().type(menuData2.data.title); + cy.contains(menuData1.data.title).should("not.exist"); + cy.contains(menuData2.data.title).should("exist"); + cy.contains(menuData3.data.title).should("not.exist"); + cy.contains(menuData4.data.title).should("not.exist"); + + cy.findByPlaceholderText("Search menus").clear().type(menuData3.data.title); + cy.contains(menuData1.data.title).should("not.exist"); + cy.contains(menuData2.data.title).should("not.exist"); + cy.contains(menuData3.data.title).should("exist"); + cy.contains(menuData4.data.title).should("not.exist"); + + cy.findByPlaceholderText("Search menus").clear().type(menuData4.data.title); + cy.contains(menuData1.data.title).should("not.exist"); + cy.contains(menuData2.data.title).should("not.exist"); + cy.contains(menuData3.data.title).should("not.exist"); + cy.contains(menuData4.data.title).should("exist"); + + cy.findByPlaceholderText("Search menus") + .clear() + .type("Random string which should return no values."); + cy.contains(menuData1.data.title).should("not.exist"); + cy.contains(menuData2.data.title).should("not.exist"); + cy.contains(menuData3.data.title).should("not.exist"); + cy.contains(menuData4.data.title).should("not.exist"); + + cy.findByPlaceholderText("Search menus").clear(); + + // Using the sorting filter assert all the options are being correctly displayed. + cy.findByTestId("default-data-list.filter").click(); + cy.findByTestId("ui.list.data-list").within(() => { + // Sort by date created on, Descending. + cy.get("select").select("createdOn_DESC"); + cy.findByTestId("default-data-list.filter").click(); + }); + cy.findByTestId("default-data-list").within(() => { + cy.get(".mdc-list-item") + .first() + .within(() => { + cy.contains(menuData1.data.title).should("exist"); + }); + }); + + cy.findByTestId("default-data-list.filter").click(); + cy.findByTestId("ui.list.data-list").within(() => { + // Sort by date created on, Ascending. + cy.get("select").select("createdOn_ASC"); + cy.findByTestId("default-data-list.filter").click(); + }); + cy.findByTestId("default-data-list").within(() => { + cy.get(".mdc-list-item") + .first() + .next() + .within(() => { + cy.contains(menuData4.data.title).should("exist"); + }); + }); + + cy.findByTestId("default-data-list.filter").click(); + cy.findByTestId("ui.list.data-list").within(() => { + // Sort by title, Ascending. + cy.get("select").select("title_ASC"); + cy.findByTestId("default-data-list.filter").click(); + }); + cy.findByTestId("default-data-list").within(() => { + cy.get(".mdc-list-item") + .first() + .within(() => { + cy.contains(menuData4.data.title).should("exist"); + }); + }); + + cy.findByTestId("default-data-list.filter").click(); + cy.findByTestId("ui.list.data-list").within(() => { + // Sort by title, Descending. + cy.get("select").select("title_DESC"); + cy.findByTestId("default-data-list.filter").click(); + }); + cy.findByTestId("default-data-list").within(() => { + cy.get(".mdc-list-item") + .first() + .next() + .within(() => { + cy.contains(menuData3.data.title).should("exist"); + }); + }); + }); +}); diff --git a/cypress-tests/cypress/support/commands.js b/cypress-tests/cypress/support/commands.js index ef9200b60a1..e8d92d8aedb 100644 --- a/cypress-tests/cypress/support/commands.js +++ b/cypress-tests/cypress/support/commands.js @@ -2,20 +2,24 @@ import "cypress-wait-until"; import { addMatchImageSnapshotCommand } from "cypress-image-snapshot/command"; import "./login"; import "./dropFile"; -import "./reloadUntil"; -import "./pageBuilder/pbListPages"; +import "./pageBuilder/pbListMenus"; +import "./pageBuilder/pbDeleteMenu"; import "./pageBuilder/pbCreatePage"; +import "./pageBuilder/pbListPages"; +import "./pageBuilder/reloadUntil"; +import "./pageBuilder/pbCreateMenu"; +import "./pageBuilder/pbDeleteAllMenus"; import "./pageBuilder/pbCreatePageTemplate"; import "./pageBuilder/pbCreateBlock"; +import "./pageBuilder/pbClearMainMenu"; import "./pageBuilder/pbCreateCategory"; import "./pageBuilder/pbCreateCategoryAndBlocks"; import "./pageBuilder/pbUpdatePage"; import "./pageBuilder/pbCreateCategory"; import "./pageBuilder/pbDeleteAllCategories"; import "./pageBuilder/pbPublishPage"; +import "./pageBuilder/pbDeleteAllPages"; import "./pageBuilder/pbDeletePage"; -import "./pageBuilder/pbCreateMenu"; -import "./pageBuilder/pbDeleteMenu"; import "./pageBuilder/pbUpdatePageTemplate"; import "./pageBuilder/pbListPageTemplates"; import "./pageBuilder/pbListPageBlocks"; @@ -25,7 +29,6 @@ import "./pageBuilder/pbDeleteAllBlockCategories"; import "./pageBuilder/pbListBlockCategories"; import "./pageBuilder/pbCreateCategory"; import "./pageBuilder/pbDeleteCategory"; -import "./pageBuilder/pbListPageTemplates"; import "./headlessCms/cmsCreateContentModel"; import "./headlessCms/cmsUpdateContentModel"; import "./headlessCms/cmsDeleteContentModel"; diff --git a/cypress-tests/cypress/support/login/authenticateWithCognito.ts b/cypress-tests/cypress/support/login/authenticateWithCognito.ts index 5edae5bc5e1..376f06c3270 100644 --- a/cypress-tests/cypress/support/login/authenticateWithCognito.ts +++ b/cypress-tests/cypress/support/login/authenticateWithCognito.ts @@ -1,5 +1,8 @@ -const AmazonCognitoIdentity = require("amazon-cognito-identity-js"); -global.fetch = require("node-fetch"); +import * as AmazonCognitoIdentity from "amazon-cognito-identity-js"; +import fetch from "node-fetch"; + +// @ts-expect-error +global.fetch = fetch; const AWS_COGNITO = { USER_POOL_ID: Cypress.env("AWS_COGNITO_USER_POOL_ID"), @@ -11,7 +14,7 @@ const userPool = new AmazonCognitoIdentity.CognitoUserPool({ ClientId: AWS_COGNITO.CLIENT_ID }); -export default ({ username, password }) => { +export default ({ username, password }: { username: string; password: string }) => { const userData = { Username: username, Pool: userPool diff --git a/cypress-tests/cypress/support/login/index.ts b/cypress-tests/cypress/support/login/index.ts index bdcc74c9be8..7ad5ded79a8 100644 --- a/cypress-tests/cypress/support/login/index.ts +++ b/cypress-tests/cypress/support/login/index.ts @@ -29,12 +29,16 @@ export const login = async ({ username, password } = DEFAULT_LOGIN) => { }); }; +export const getSuperAdminUser = () => { + return login(); +}; + interface LoginParams { username: string; password: string; } -interface User { +export interface User { idToken: { jwtToken: string; }; diff --git a/cypress-tests/cypress/support/pageBuilder/pbClearMainMenu.ts b/cypress-tests/cypress/support/pageBuilder/pbClearMainMenu.ts new file mode 100644 index 00000000000..6969a26bff1 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbClearMainMenu.ts @@ -0,0 +1,61 @@ +// Corrected pbClearMainMenu.ts + +import { gqlClient } from "../utils"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbClearMainMenu(): Chainable< + Promise<{ title: string; slug: string; description: string; items: string[] }> + >; + } + } +} + +// GraphQL mutation to clear menu items +const CLEAR_MAIN_MENU = ` + mutation updateMenu($slug: String!, $data: PbMenuInput!) { + pageBuilder { + menu: updateMenu(slug: $slug, data: $data) { + data { + title + slug + description + items + } + } + } + } +`; + +Cypress.Commands.add("pbClearMainMenu", () => { + const variables = { + slug: "main-menu", + data: { + slug: "main-menu", + title: "Main Menu", + description: "Main Menu description", + items: [] + } + }; + + cy.login().then(user => { + return gqlClient + .request({ + query: CLEAR_MAIN_MENU, + variables, // No comma needed here + authToken: user.idToken.jwtToken + }) + .then(response => { + // Handle the response as needed + if (response.pageBuilder.menu.error) { + throw new Error( + `Failed to clear menu items: ${response.pageBuilder.menu.error.message}` + ); + } + + return response.pageBuilder.menu.data; + }); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.js b/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.js deleted file mode 100644 index d151a0eaefa..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { CREATE_MENU } from "./graphql"; - -Cypress.Commands.add("pbCreateMenu", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(CREATE_MENU, variables) - .then(response => response.pageBuilder.menu.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.ts b/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.ts new file mode 100644 index 00000000000..4a14ad3c811 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbCreateMenu.ts @@ -0,0 +1,39 @@ +import { gqlClient } from "../utils"; + +const CREATE_MENU = /* GraphQL */ ` + mutation CreateMenu($data: PbMenuInput!) { + pageBuilder { + menu: createMenu(data: $data) { + data { + title + slug + description + items + } + } + } + } +`; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbCreateMenu( + data: any + ): Promise<{ title: string; slug: string; description: string; items: any[] }>; + } + } +} + +Cypress.Commands.add("pbCreateMenu", variables => { + cy.login().then(user => { + return gqlClient + .request({ + query: CREATE_MENU, + variables, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.menu.data); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbCreatePage.js b/cypress-tests/cypress/support/pageBuilder/pbCreatePage.js deleted file mode 100644 index 310b7fccfe8..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbCreatePage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { CREATE_PAGE } from "./graphql"; - -Cypress.Commands.add("pbCreatePage", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(CREATE_PAGE, variables) - .then(response => response.pageBuilder.createPage.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbCreatePage.ts b/cypress-tests/cypress/support/pageBuilder/pbCreatePage.ts new file mode 100644 index 00000000000..a3bad1ce876 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbCreatePage.ts @@ -0,0 +1,38 @@ +import { gqlClient } from "../utils"; + +const CREATE_PAGE = /* GraphQL */ ` + mutation PbCreatePage($from: ID, $category: String, $meta: JSON) { + pageBuilder { + createPage(from: $from, category: $category, meta: $meta) { + data { + id + title + category { + name + slug + } + } + } + } + } +`; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbCreatePage(data: any): Promise<{ id: string; title: string; category: string[] }>; + } + } +} +Cypress.Commands.add("pbCreatePage", variables => { + return cy.login().then(user => { + return gqlClient + .request({ + query: CREATE_PAGE, + variables, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.createPage.data); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeleteAllMenus.ts b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllMenus.ts new file mode 100644 index 00000000000..d37b1456ca0 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllMenus.ts @@ -0,0 +1,74 @@ +import { gqlClient } from "../utils"; + +const LIST_MENUS_QUERY = /* GraphQL */ ` + query listMenus { + pageBuilder { + listMenus { + data { + slug + } + } + } + } +`; + +const DELETE_MENU_MUTATION = /* GraphQL */ ` + mutation deleteMenu($slug: String!) { + pageBuilder { + deleteMenu(slug: $slug) { + error { + code + message + } + } + } + } +`; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + listMenus(): Promise[]>; + deleteMenu(slug: string): Promise>; + pbDeleteAllMenus(): void; + } + } +} + +Cypress.Commands.add("listMenus", () => { + return cy.login().then(user => { + return gqlClient + .request({ + query: LIST_MENUS_QUERY, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.listMenus.data); + }); +}); + +Cypress.Commands.add("deleteMenu", slug => { + return cy.login().then(user => { + return gqlClient + .request({ + query: DELETE_MENU_MUTATION, + variables: { slug }, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.deleteMenu); + }); +}); + +Cypress.Commands.add("pbDeleteAllMenus", () => { + return cy.listMenus().then(menus => { + return Promise.all( + menus.map(menu => { + if (menu.slug !== "main-menu") { + return cy.deleteMenu(menu.slug); + } else { + return Promise.resolve(); // Return a resolved promise to continue the Promise.all + } + }) + ); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeleteAllPages.ts b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllPages.ts new file mode 100644 index 00000000000..a88f580b510 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllPages.ts @@ -0,0 +1,42 @@ +import { until } from "@webiny/project-utils/testing/helpers/until"; +import { login } from "../login"; +import { pbListPages } from "./pbListPages"; +import { pbDeletePage } from "./pbDeletePage"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbDeleteAllPages(): Promise; + } + } +} + +Cypress.Commands.add("pbDeleteAllPages", async () => { + // Loads admin user with full access permissions. + const user = await login(); + + const pages = await pbListPages({ user, variables: { limit: 100 } }); + for (let i = 0; i < pages.length; i++) { + if (pages[i].path === "/welcome-to-webiny") { + continue; + } + + if (pages[i].path === "/not-found") { + continue; + } + + await pbDeletePage( + { + id: pages[i].pid + }, + { user } + ); + } + + return until( + () => pbListPages({ user }), + result => result.length <= 2, + { wait: 3000 } + ); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeleteAllTemplates.ts b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllTemplates.ts index 72d24c6930d..424621b895a 100644 --- a/cypress-tests/cypress/support/pageBuilder/pbDeleteAllTemplates.ts +++ b/cypress-tests/cypress/support/pageBuilder/pbDeleteAllTemplates.ts @@ -5,7 +5,7 @@ declare global { namespace Cypress { interface Chainable { // Combined method to list and delete all templates - pbDeleteAllTemplates(): Promise; + pbDeleteAllTemplates(): Promise; } } } @@ -48,21 +48,14 @@ Cypress.Commands.add("pbDeleteAllTemplates", () => { // Loop through the templates and delete each one. return Promise.all( - templates.map((template: { id: string }) => { - return gqlClient - .request({ - query: DELETE_MUTATION, - variables: { - id: template.id - }, - authToken: user.idToken.jwtToken - }) - .then(deleteResponse => { - const error = deleteResponse.pageBuilder.deletePageTemplate.error; - if (error) { - cy.log(`Error deleting template with ID: ${template.id}`); - } - }); + templates.map(async (template: { id: string }) => { + await gqlClient.request({ + query: DELETE_MUTATION, + variables: { + id: template.id + }, + authToken: user.idToken.jwtToken + }); }) ); }); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.js b/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.js deleted file mode 100644 index d5d7a759d64..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { DELETE_MENU } from "./graphql"; - -Cypress.Commands.add("pbDeleteMenu", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(DELETE_MENU, variables) - .then(response => response.pageBuilder.menu.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.ts b/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.ts new file mode 100644 index 00000000000..6336315c4fb --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbDeleteMenu.ts @@ -0,0 +1,35 @@ +import { gqlClient } from "../utils"; + +const DELETE_MENU_MUTATION = /* GraphQL */ ` + mutation deleteMenu($slug: String!) { + pageBuilder { + deleteMenu(slug: $slug) { + error { + code + message + } + } + } + } +`; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbDeleteMenu(slug: string): Promise>; + } + } +} + +Cypress.Commands.add("pbDeleteMenu", slug => { + cy.login().then(user => { + return gqlClient + .request({ + query: DELETE_MENU_MUTATION, + variables: { slug }, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.deleteMenu); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeletePage.js b/cypress-tests/cypress/support/pageBuilder/pbDeletePage.js deleted file mode 100644 index 74c75a6eb95..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbDeletePage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { DELETE_PAGE } from "./graphql"; - -Cypress.Commands.add("pbDeletePage", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(DELETE_PAGE, variables) - .then(response => response.pageBuilder.deletePage.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbDeletePage.ts b/cypress-tests/cypress/support/pageBuilder/pbDeletePage.ts new file mode 100644 index 00000000000..5a9ff3d1a27 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbDeletePage.ts @@ -0,0 +1,28 @@ +import { createGqlQuery, GqlResponse } from "../utils"; + +const DELETE_PAGE = /* GraphQL */ ` + mutation DeletePage($id: ID!) { + pageBuilder { + deletePage(id: $id) { + error { + message + data + code + } + } + } + } +`; + +export const pbDeletePage = createGqlQuery, { id: string }>(DELETE_PAGE); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbDeletePage: typeof pbDeletePage; + } + } +} + +Cypress.Commands.add("pbDeletePage", pbDeletePage); diff --git a/cypress-tests/cypress/support/pageBuilder/pbListMenus.ts b/cypress-tests/cypress/support/pageBuilder/pbListMenus.ts new file mode 100644 index 00000000000..c806343ad5e --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbListMenus.ts @@ -0,0 +1,36 @@ +import { createGqlQuery, GqlListResponse } from "../utils"; + +const LIST_MENUS_QUERY = /* GraphQL */ ` + query pbListMenus { + pageBuilder { + listMenus { + data { + title + slug + description + items + } + } + } + } +`; + +export const pbListMenus = createGqlQuery< + GqlListResponse<{ + title: string; + slug: string; + description: string; + items: Record; + }> +>(LIST_MENUS_QUERY); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbListMenus: typeof pbListMenus; + } + } +} + +Cypress.Commands.add("pbListMenus", pbListMenus); diff --git a/cypress-tests/cypress/support/pageBuilder/pbListPages.js b/cypress-tests/cypress/support/pageBuilder/pbListPages.js deleted file mode 100644 index 3313afc8aee..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbListPages.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { LIST_PAGES } from "./graphql"; - -Cypress.Commands.add("pbListPages", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(LIST_PAGES, variables) - .then(response => response.pageBuilder.listPages.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbListPages.ts b/cypress-tests/cypress/support/pageBuilder/pbListPages.ts new file mode 100644 index 00000000000..3652d2b6018 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbListPages.ts @@ -0,0 +1,54 @@ +import { gqlClient } from "../utils"; +import { login, User } from "../login"; + +const LIST_PAGES = /* GraphQL */ ` + query PbListPages( + $where: PbListPagesWhereInput + $sort: [PbListPagesSort!] + $search: PbListPagesSearchInput + $limit: Int + $after: String + ) { + pageBuilder { + listPages(where: $where, sort: $sort, limit: $limit, after: $after, search: $search) { + data { + id + pid + title + path + status + } + } + } + } +`; + +interface PbListPagesParams { + user: User; + variables?: Record; +} + +export const pbListPages = ({ user, variables = {} }: PbListPagesParams) => { + return gqlClient + .request({ + query: LIST_PAGES, + variables, + authToken: user.idToken.jwtToken + }) + .then(response => response.pageBuilder.listPages.data); +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbListPages(variables: Record): ReturnType; + } + } +} + +Cypress.Commands.add("pbListPages", (variables = {}) => { + return login().then(user => { + return pbListPages({ user, variables }); + }); +}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbPublishPage.js b/cypress-tests/cypress/support/pageBuilder/pbPublishPage.js deleted file mode 100644 index 832274f3c0d..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbPublishPage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { PUBLISH_PAGE } from "./graphql"; - -Cypress.Commands.add("pbPublishPage", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(PUBLISH_PAGE, variables) - .then(response => response.pageBuilder.publishPage.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbPublishPage.ts b/cypress-tests/cypress/support/pageBuilder/pbPublishPage.ts new file mode 100644 index 00000000000..24e71b51132 --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbPublishPage.ts @@ -0,0 +1,28 @@ +import { createGqlQuery, GqlResponse } from "../utils"; + +const PUBLISH_PAGE = /* GraphQL */ ` + mutation PbPublishPage($id: ID!) { + pageBuilder { + publishPage(id: $id) { + data { + id + } + } + } + } +`; + +export const pbPublishPage = createGqlQuery, { id: string }>( + PUBLISH_PAGE +); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbPublishPage: typeof pbPublishPage; + } + } +} + +Cypress.Commands.add("pbPublishPage", pbPublishPage); diff --git a/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.js b/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.js deleted file mode 100644 index 8c724a78871..00000000000 --- a/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { UPDATE_PAGE } from "./graphql"; - -Cypress.Commands.add("pbUpdatePage", variables => { - cy.login().then(user => { - const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { - headers: { - authorization: `Bearer ${user.idToken.jwtToken}` - } - }); - - return client - .request(UPDATE_PAGE, variables) - .then(response => response.pageBuilder.updatePage.data); - }); -}); diff --git a/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.ts b/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.ts new file mode 100644 index 00000000000..6d1dfaea86b --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/pbUpdatePage.ts @@ -0,0 +1,30 @@ +import { createGqlQuery, GqlResponse } from "../utils"; + +const UPDATE_PAGE = /* GraphQL */ ` + mutation updatePage($id: ID!, $data: PbUpdatePageInput!) { + pageBuilder { + updatePage(id: $id, data: $data) { + data { + id + title + } + } + } + } +`; + +export const pbUpdatePage = createGqlQuery< + GqlResponse<{ id: string; title: string }>, + { id: string; data: object } +>(UPDATE_PAGE); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + pbUpdatePage: typeof pbUpdatePage; + } + } +} + +Cypress.Commands.add("pbUpdatePage", pbUpdatePage); diff --git a/cypress-tests/cypress/support/pageBuilder/reloadUntil.ts b/cypress-tests/cypress/support/pageBuilder/reloadUntil.ts new file mode 100644 index 00000000000..4683a0b32ad --- /dev/null +++ b/cypress-tests/cypress/support/pageBuilder/reloadUntil.ts @@ -0,0 +1,75 @@ +const MAX_RETRIES = 100; +const WAIT_BETWEEN_RETRIES = 3000; +const REPEAT_WAIT_BETWEEN_RETRIES = 2000; + +export const sleep = (ms = 1000) => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + reloadUntil(callback: () => Promise | boolean, options?: any): Chainable; + } + } +} + +// This will ensure the page is tested for 10 minutes, until the test can be considered as failed. +Cypress.Commands.add("reloadUntil", (callback, options = {}) => { + return cy.log(`Reloading until a condition is met...`).then(() => { + let retries = -1; + let repeat = 0; + + function check(): any { + retries++; + // @ts-expect-error + return cy.then(async () => { + await sleep(REPEAT_WAIT_BETWEEN_RETRIES); + const result = await callback(); + try { + if (!result) { + throw Error(); + } + + // Sometimes, reloading the page can still return previous, not-wanted result. To avoid this, + // users can pass `options.repeat`, which will reload the page and make the extra assertions + // `options.repeat` times. + if (options.repeat > 0) { + repeat++; + if (repeat <= options.repeat) { + if (repeat === 1) { + cy.log( + `Success, but repeating the assertion ${options.repeat} times, to be extra sure.` + ); + } + cy.log(`Assertion repeat ${repeat} / ${options.repeat}.`); + return cy + .log(`Reloading (attempt #${retries + 1})...`) + .reload() + .then(() => { + return check(); + }); + } + } + return cy.log("Condition met, moving on..."); + } catch (err) { + if (retries > MAX_RETRIES) { + throw new Error(`retried too many times (${--retries})`); + } + + await sleep(WAIT_BETWEEN_RETRIES); + return cy + .log(`Reloading (attempt #${retries + 1})...`) + .reload() + .then(() => { + return check(); + }); + } + }); + } + + return check(); + }); +}); diff --git a/cypress-tests/cypress/support/utils.ts b/cypress-tests/cypress/support/utils.ts index b587bd1a4d2..1742da21140 100644 --- a/cypress-tests/cypress/support/utils.ts +++ b/cypress-tests/cypress/support/utils.ts @@ -1,5 +1,6 @@ import { GraphQLClient } from "graphql-request"; import { customAlphabet } from "nanoid"; +import { getSuperAdminUser, User } from "./login"; const DEFAULT_TENANT_ID = "root"; @@ -15,26 +16,96 @@ interface RequestParams { tenantId?: string; } +export interface GqlResponseError { + message: string; + code: string; + data: any; +} + +export interface GqlResponse> { + data: TData; + error: GqlResponseError | null; +} + +export interface GqlListResponse, TMeta = Record> { + data: TData[]; + meta: TMeta; + error: GqlResponseError | null; +} + export const createGqlClient = (gqlClientOptions: CreateGqlClientParams = {}) => { const gqlClient = new GraphQLClient(Cypress.env("GRAPHQL_API_URL")); + const request = >({ + query, + variables, + authToken, + tenantId + }: RequestParams) => { + return gqlClient.request(query, variables, { + authorization: `Bearer ${authToken || gqlClientOptions.authToken}`, + ["x-tenant"]: tenantId || gqlClientOptions.tenantId || DEFAULT_TENANT_ID + }); + }; + + const query = (params: RequestParams) => { + return request(params).then(response => { + // TODO: could be improved. + const [appName] = Object.keys(response); + const [gqlOperationName] = Object.keys(response[appName]); + + const data = response[appName][gqlOperationName] as TResponse; + + if (response.error) { + console.error( + `An error occurred while executing ${appName}.${gqlOperationName} GraphQl operation.`, + { + params, + response + } + ); + } + + return data; + }); + }; + return { - request: function request>({ - query, - variables, - authToken, - tenantId - }: RequestParams) { - return gqlClient.request(query, variables, { - authorization: `Bearer ${authToken || gqlClientOptions.authToken}`, - ["x-tenant"]: tenantId || gqlClientOptions.tenantId || DEFAULT_TENANT_ID - }); - } + request, + query }; }; export const gqlClient = createGqlClient(); +interface GqlQueryOptions { + user?: User; +} + +type GqlQueryFunction = ( + variables: TVariables, + options?: GqlQueryOptions +) => Promise; + +export const createGqlQuery = >( + query: string +): GqlQueryFunction => { + return async (variables: TVariables, options?: GqlQueryOptions) => { + let user = options?.user; + if (!user) { + user = await getSuperAdminUser(); + } + + const authToken = user?.idToken.jwtToken; + + return gqlClient.query({ + query, + variables, + authToken + }); + }; +}; + export const generateId = () => { return customAlphabet("abcdefghijklmnopqrstuvwxyz", 10)(); }; diff --git a/cypress-tests/package.json b/cypress-tests/package.json index 127ecb11626..8cec83edc8f 100644 --- a/cypress-tests/package.json +++ b/cypress-tests/package.json @@ -8,6 +8,7 @@ "@4tw/cypress-drag-drop": "^1.4.0", "@testing-library/cypress": "^10.0.0", "@webiny/project-utils": "0.0.0", + "@webiny/utils": "0.0.0", "amazon-cognito-identity-js": "^4.5.3", "cypress": "^13.0.0", "cypress-image-snapshot": "^4.0.1", @@ -18,6 +19,7 @@ "lodash": "^4.17.11", "nanoid": "^3.0.0", "node-fetch": "^2.6.1", + "typescript": "4.7.4", "uniqid": "^5.2.0" }, "scripts": { diff --git a/cypress-tests/tsconfig.json b/cypress-tests/tsconfig.json index 962e629969a..2f772a24de8 100644 --- a/cypress-tests/tsconfig.json +++ b/cypress-tests/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "baseUrl": ".", "strict": true, "target": "es5", diff --git a/package.json b/package.json index 498b3ab60d7..671f1be6f53 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "rimraf": "^3.0.2", "rxjs": "^6.5.5", "semver": "^7.5.4", + "ts-expect": "^1.3.0", "ts-jest": "^29.1.0", "typescript": "4.7.4", "typescript-transform-paths": "^2.2.3", @@ -134,6 +135,8 @@ "cy:open": "yarn cypress:open", "cypress:run": "cd cypress-tests && yarn cypress run", "cy:run": "yarn cypress:run", + "cypress:ts": "cd cypress-tests && yarn tsc --noEmit", + "cy:ts": "yarn cypress:ts", "webiny-versions": "node ./scripts/webinyVersions.js", "trigger-release": "node ./scripts/release/triggerRelease.js", "dispatch-github-event": "node ./scripts/dispatchGitHubEvent.js", diff --git a/packages/api-aco/__tests__/snapshots/customAppsSchema.ts b/packages/api-aco/__tests__/snapshots/customAppsSchema.ts index 572087e5dba..b750278ccc6 100644 --- a/packages/api-aco/__tests__/snapshots/customAppsSchema.ts +++ b/packages/api-aco/__tests__/snapshots/customAppsSchema.ts @@ -195,6 +195,13 @@ export const createCustomAppsSchemaSnapshot = () => { createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -202,21 +209,40 @@ export const createCustomAppsSchemaSnapshot = () => { savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -224,13 +250,6 @@ export const createCustomAppsSchemaSnapshot = () => { revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -238,6 +257,13 @@ export const createCustomAppsSchemaSnapshot = () => { revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -256,14 +282,14 @@ export const createCustomAppsSchemaSnapshot = () => { revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -272,61 +298,6 @@ export const createCustomAppsSchemaSnapshot = () => { revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -384,30 +355,26 @@ export const createCustomAppsSchemaSnapshot = () => { enum AcoSearchRecordCustomTestingAppListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC type_ASC type_DESC title_ASC diff --git a/packages/api-aco/__tests__/snapshots/defaultAppsSchema.ts b/packages/api-aco/__tests__/snapshots/defaultAppsSchema.ts index 62dd63e78f1..fe1c3ad8171 100644 --- a/packages/api-aco/__tests__/snapshots/defaultAppsSchema.ts +++ b/packages/api-aco/__tests__/snapshots/defaultAppsSchema.ts @@ -169,6 +169,13 @@ export const createDefaultAppsSchemaSnapshot = () => { createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -176,21 +183,40 @@ export const createDefaultAppsSchemaSnapshot = () => { savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -198,13 +224,6 @@ export const createDefaultAppsSchemaSnapshot = () => { revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -212,6 +231,13 @@ export const createDefaultAppsSchemaSnapshot = () => { revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -230,14 +256,14 @@ export const createDefaultAppsSchemaSnapshot = () => { revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -246,61 +272,6 @@ export const createDefaultAppsSchemaSnapshot = () => { revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -358,30 +329,26 @@ export const createDefaultAppsSchemaSnapshot = () => { enum AcoSearchRecordWebinyListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC type_ASC type_DESC title_ASC diff --git a/packages/api-aco/src/utils/pickEntryFieldValues.ts b/packages/api-aco/src/utils/pickEntryFieldValues.ts index 9b56423c41a..805f1af3d72 100644 --- a/packages/api-aco/src/utils/pickEntryFieldValues.ts +++ b/packages/api-aco/src/utils/pickEntryFieldValues.ts @@ -1,17 +1,15 @@ import { CmsEntry } from "@webiny/api-headless-cms/types"; -import { SearchRecord } from "~/record/record.types"; export const baseFields = [ // Entry ID is mapped to "id" (we don't use revisions with ACO entities). "id", - // On/by fields are mapped to entry-level fields (we use ":" to signal that). - "entryCreatedOn:createdOn", - "entryModifiedOn:modifiedOn", - "entrySavedOn:savedOn", - "entryCreatedBy:createdBy", - "entryModifiedBy:modifiedBy", - "entrySavedBy:savedBy" + "createdOn", + "modifiedOn", + "savedOn", + "createdBy", + "modifiedBy", + "savedBy" ]; const pickBaseEntryFieldValues = (entry: CmsEntry) => { @@ -34,17 +32,3 @@ export function pickEntryFieldValues(entry: CmsEntry): T { ...entry.values } as T; } - -export function getRecordFieldValues(entry: CmsEntry, baseFields?: string[]) { - if (baseFields) { - return { - ...pickBaseEntryFieldValues(entry), - ...entry.values - } as SearchRecord; - } - - return { - ...entry, - ...entry.values - } as SearchRecord; -} diff --git a/packages/api-admin-users/src/createAdminUsers/users.validation.ts b/packages/api-admin-users/src/createAdminUsers/users.validation.ts index 8d72e8a2c49..ea413c250a9 100644 --- a/packages/api-admin-users/src/createAdminUsers/users.validation.ts +++ b/packages/api-admin-users/src/createAdminUsers/users.validation.ts @@ -1,37 +1,31 @@ -/** - * Package @commodo/fields does not have types - */ -// @ts-expect-error +// @ts-expect-error Package @commodo/fields does not have types. import { string, withFields } from "@commodo/fields"; -/** - * Package commodo-fields-object does not have types - */ -// @ts-expect-error +// @ts-expect-error Package commodo-fields-object does not have types. import { object } from "commodo-fields-object"; import { validation } from "@webiny/validation"; import { AdminUsers } from "~/types"; const CreateUserDataModel = withFields({ - id: string({ validation: validation.create("minLength:2") }), - displayName: string({ validation: validation.create("minLength:2") }), + id: string({ validation: validation.create("minLength:1") }), + displayName: string({ validation: validation.create("minLength:1") }), // We did not use an e-mail validator here, just because external // IdPs (Okta, Auth0) do not require e-mail to be present. When creating // admin users, they're actually passing the user's ID as the e-mail. // For example: packages/api-security-okta/src/createAdminUsersHooks.ts:13 // In the future, we might want to rename this field to `idpId` or similar. - email: string({ validation: validation.create("required,minLength:2") }), + email: string({ validation: validation.create("required") }), - firstName: string({ validation: validation.create("minLength:2") }), - lastName: string({ validation: validation.create("minLength:2") }), + firstName: string({ validation: validation.create("required") }), + lastName: string({ validation: validation.create("required") }), avatar: object() })(); const UpdateUserDataModel = withFields({ - displayName: string({ validation: validation.create("minLength:2") }), + displayName: string({ validation: validation.create("minLength:1") }), avatar: object(), - firstName: string({ validation: validation.create("minLength:2") }), - lastName: string({ validation: validation.create("minLength:2") }), + firstName: string({ validation: validation.create("minLength:1") }), + lastName: string({ validation: validation.create("minLength:1") }), group: string(), team: string() })(); diff --git a/packages/api-apw/src/plugins/graphql/comment.gql.ts b/packages/api-apw/src/plugins/graphql/comment.gql.ts index 2a14ff9e4bc..9a26217d178 100644 --- a/packages/api-apw/src/plugins/graphql/comment.gql.ts +++ b/packages/api-apw/src/plugins/graphql/comment.gql.ts @@ -103,11 +103,6 @@ const workflowSchema = new GraphQLSchemaPlugin({ ${dateTimeWhereFields} ${identityWhereFields} - ownedBy: String @deprecated(reason: "Use 'createdBy'.") - ownedBy_not: String @deprecated(reason: "Use 'createdBy_not'.") - ownedBy_in: [String!] @deprecated(reason: "Use 'createdBy_in'.") - ownedBy_not_in: [String!] @deprecated(reason: "Use 'createdBy_not_in'.") - changeRequest: ApwRefFieldWhereInput } diff --git a/packages/api-apw/src/plugins/graphql/contentReview.gql.ts b/packages/api-apw/src/plugins/graphql/contentReview.gql.ts index 2a797006186..6685bf0ce85 100644 --- a/packages/api-apw/src/plugins/graphql/contentReview.gql.ts +++ b/packages/api-apw/src/plugins/graphql/contentReview.gql.ts @@ -259,7 +259,14 @@ const contentReviewSchema = new GraphQLSchemaPlugin({ if (!content) { return null; } - return content.publishedOn; + + // In case a page was returned, let's read the `publishedOn` field. + if ("publishedOn" in content) { + return content.publishedOn; + } + + // In case a CMS entry was returned, let's read the entry-level `lastPublishedOn` field. + return content.lastPublishedOn; }, publishedBy: async (parent: ApwContentReviewContent, _, context: ApwContext) => { const id = parent.publishedBy; diff --git a/packages/api-apw/src/plugins/hooks/updateTotalComments.ts b/packages/api-apw/src/plugins/hooks/updateTotalComments.ts index 6dc8ef1f832..a032697ecdd 100644 --- a/packages/api-apw/src/plugins/hooks/updateTotalComments.ts +++ b/packages/api-apw/src/plugins/hooks/updateTotalComments.ts @@ -109,7 +109,7 @@ export const updateLatestCommentId = ({ apw }: Pick { content: publish${ucFirstModelId}(revision: $revision) { data { id + lastPublishedOn meta { ${META_FIELDS} } @@ -50,6 +50,7 @@ const createUnpublishMutation = (modelId: string) => { content: unpublish${ucFirstModelId}(revision: $revision) { data { id + lastPublishedOn meta { ${META_FIELDS} } diff --git a/packages/api-apw/src/utils/pickEntryFieldValues.ts b/packages/api-apw/src/utils/pickEntryFieldValues.ts index 49074145cf2..805f1af3d72 100644 --- a/packages/api-apw/src/utils/pickEntryFieldValues.ts +++ b/packages/api-apw/src/utils/pickEntryFieldValues.ts @@ -4,13 +4,12 @@ export const baseFields = [ // Entry ID is mapped to "id" (we don't use revisions with ACO entities). "id", - // On/by fields are mapped to entry-level fields (we use ":" to signal that). - "entryCreatedOn:createdOn", - "entryModifiedOn:modifiedOn", - "entrySavedOn:savedOn", - "entryCreatedBy:createdBy", - "entryModifiedBy:modifiedBy", - "entrySavedBy:savedBy" + "createdOn", + "modifiedOn", + "savedOn", + "createdBy", + "modifiedBy", + "savedBy" ]; const pickBaseEntryFieldValues = (entry: CmsEntry) => { diff --git a/packages/api-background-tasks-ddb/.babelrc.js b/packages/api-background-tasks-ddb/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-background-tasks-ddb/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-background-tasks-ddb/LICENSE b/packages/api-background-tasks-ddb/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-background-tasks-ddb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-background-tasks-ddb/README.md b/packages/api-background-tasks-ddb/README.md new file mode 100644 index 00000000000..47a9674e6be --- /dev/null +++ b/packages/api-background-tasks-ddb/README.md @@ -0,0 +1,6 @@ +# @webiny/api-background-tasks-ddb + +[![](https://img.shields.io/npm/dw/@webiny/api-background-tasks-ddb.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-ddb) +[![](https://img.shields.io/npm/v/@webiny/api-background-tasks-ddb.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-ddb) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) diff --git a/packages/api-background-tasks-ddb/package.json b/packages/api-background-tasks-ddb/package.json new file mode 100644 index 00000000000..04841882b92 --- /dev/null +++ b/packages/api-background-tasks-ddb/package.json @@ -0,0 +1,33 @@ +{ + "name": "@webiny/api-background-tasks-ddb", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Wrapper for background tasks initialization for DynamoDB.", + "author": "Webiny LTD", + "license": "MIT", + "dependencies": { + "@webiny/plugins": "0.0.0", + "@webiny/tasks": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.22.6", + "@babel/core": "^7.22.8", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/api-background-tasks-ddb/src/index.ts b/packages/api-background-tasks-ddb/src/index.ts new file mode 100644 index 00000000000..2e6518734a1 --- /dev/null +++ b/packages/api-background-tasks-ddb/src/index.ts @@ -0,0 +1,6 @@ +import { Plugin } from "@webiny/plugins/types"; +import { createBackgroundTaskContext, createBackgroundTaskGraphQL } from "@webiny/tasks"; + +export const createBackgroundTasks = (): Plugin[] => { + return [...createBackgroundTaskContext(), ...createBackgroundTaskGraphQL()]; +}; diff --git a/packages/api-background-tasks-ddb/tsconfig.build.json b/packages/api-background-tasks-ddb/tsconfig.build.json new file mode 100644 index 00000000000..873f4c3a94a --- /dev/null +++ b/packages/api-background-tasks-ddb/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-ddb/tsconfig.json b/packages/api-background-tasks-ddb/tsconfig.json new file mode 100644 index 00000000000..81267225ff0 --- /dev/null +++ b/packages/api-background-tasks-ddb/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [{ "path": "../plugins" }, { "path": "../tasks" }], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-ddb/webiny.config.js b/packages/api-background-tasks-ddb/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-background-tasks-ddb/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api-background-tasks-es/.babelrc.js b/packages/api-background-tasks-es/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-background-tasks-es/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-background-tasks-es/LICENSE b/packages/api-background-tasks-es/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-background-tasks-es/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-background-tasks-es/README.md b/packages/api-background-tasks-es/README.md new file mode 100644 index 00000000000..0f85b800fce --- /dev/null +++ b/packages/api-background-tasks-es/README.md @@ -0,0 +1,6 @@ +# @webiny/api-background-tasks-es + +[![](https://img.shields.io/npm/dw/@webiny/api-background-tasks-es.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-es) +[![](https://img.shields.io/npm/v/@webiny/api-background-tasks-es.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-es) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) diff --git a/packages/api-background-tasks-es/package.json b/packages/api-background-tasks-es/package.json new file mode 100644 index 00000000000..9d2077656a5 --- /dev/null +++ b/packages/api-background-tasks-es/package.json @@ -0,0 +1,34 @@ +{ + "name": "@webiny/api-background-tasks-es", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Wrapper for background tasks initialization for Elasticsearch.", + "author": "Webiny LTD", + "license": "MIT", + "dependencies": { + "@webiny/api-elasticsearch-tasks": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/tasks": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.22.6", + "@babel/core": "^7.22.8", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/api-background-tasks-es/src/index.ts b/packages/api-background-tasks-es/src/index.ts new file mode 100644 index 00000000000..5e7808ab75d --- /dev/null +++ b/packages/api-background-tasks-es/src/index.ts @@ -0,0 +1,11 @@ +import { Plugin } from "@webiny/plugins/types"; +import { createBackgroundTaskGraphQL, createBackgroundTaskContext } from "@webiny/tasks"; +import { createElasticsearchBackgroundTasks } from "@webiny/api-elasticsearch-tasks"; + +export const createBackgroundTasks = (): Plugin[] => { + return [ + ...createBackgroundTaskContext(), + ...createBackgroundTaskGraphQL(), + ...createElasticsearchBackgroundTasks() + ]; +}; diff --git a/packages/api-background-tasks-es/tsconfig.build.json b/packages/api-background-tasks-es/tsconfig.build.json new file mode 100644 index 00000000000..a24fe8b6152 --- /dev/null +++ b/packages/api-background-tasks-es/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api-elasticsearch-tasks/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-es/tsconfig.json b/packages/api-background-tasks-es/tsconfig.json new file mode 100644 index 00000000000..b98d9beebb2 --- /dev/null +++ b/packages/api-background-tasks-es/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api-elasticsearch-tasks" }, + { "path": "../plugins" }, + { "path": "../tasks" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-elasticsearch-tasks/*": ["../api-elasticsearch-tasks/src/*"], + "@webiny/api-elasticsearch-tasks": ["../api-elasticsearch-tasks/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-es/webiny.config.js b/packages/api-background-tasks-es/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-background-tasks-es/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api-background-tasks-os/.babelrc.js b/packages/api-background-tasks-os/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-background-tasks-os/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-background-tasks-os/LICENSE b/packages/api-background-tasks-os/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-background-tasks-os/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-background-tasks-os/README.md b/packages/api-background-tasks-os/README.md new file mode 100644 index 00000000000..c91b547233c --- /dev/null +++ b/packages/api-background-tasks-os/README.md @@ -0,0 +1,6 @@ +# @webiny/api-background-tasks-os + +[![](https://img.shields.io/npm/dw/@webiny/api-background-tasks-os.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-os) +[![](https://img.shields.io/npm/v/@webiny/api-background-tasks-os.svg)](https://www.npmjs.com/package/@webiny/api-background-tasks-os) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) diff --git a/packages/api-background-tasks-os/package.json b/packages/api-background-tasks-os/package.json new file mode 100644 index 00000000000..83a3f5b8c06 --- /dev/null +++ b/packages/api-background-tasks-os/package.json @@ -0,0 +1,34 @@ +{ + "name": "@webiny/api-background-tasks-os", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Wrapper for background tasks initialization for OpenSearch.", + "author": "Webiny LTD", + "license": "MIT", + "dependencies": { + "@webiny/api-elasticsearch-tasks": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/tasks": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.22.6", + "@babel/core": "^7.22.8", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.12", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/api-background-tasks-os/src/index.ts b/packages/api-background-tasks-os/src/index.ts new file mode 100644 index 00000000000..5e7808ab75d --- /dev/null +++ b/packages/api-background-tasks-os/src/index.ts @@ -0,0 +1,11 @@ +import { Plugin } from "@webiny/plugins/types"; +import { createBackgroundTaskGraphQL, createBackgroundTaskContext } from "@webiny/tasks"; +import { createElasticsearchBackgroundTasks } from "@webiny/api-elasticsearch-tasks"; + +export const createBackgroundTasks = (): Plugin[] => { + return [ + ...createBackgroundTaskContext(), + ...createBackgroundTaskGraphQL(), + ...createElasticsearchBackgroundTasks() + ]; +}; diff --git a/packages/api-background-tasks-os/tsconfig.build.json b/packages/api-background-tasks-os/tsconfig.build.json new file mode 100644 index 00000000000..a24fe8b6152 --- /dev/null +++ b/packages/api-background-tasks-os/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api-elasticsearch-tasks/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-os/tsconfig.json b/packages/api-background-tasks-os/tsconfig.json new file mode 100644 index 00000000000..b98d9beebb2 --- /dev/null +++ b/packages/api-background-tasks-os/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api-elasticsearch-tasks" }, + { "path": "../plugins" }, + { "path": "../tasks" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-elasticsearch-tasks/*": ["../api-elasticsearch-tasks/src/*"], + "@webiny/api-elasticsearch-tasks": ["../api-elasticsearch-tasks/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-background-tasks-os/webiny.config.js b/packages/api-background-tasks-os/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-background-tasks-os/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api-elasticsearch-tasks/.babelrc.js b/packages/api-elasticsearch-tasks/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-elasticsearch-tasks/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-elasticsearch-tasks/LICENSE b/packages/api-elasticsearch-tasks/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-elasticsearch-tasks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-elasticsearch-tasks/README.md b/packages/api-elasticsearch-tasks/README.md new file mode 100644 index 00000000000..16d086432d9 --- /dev/null +++ b/packages/api-elasticsearch-tasks/README.md @@ -0,0 +1,10 @@ +# @webiny/api-elasticsearch-tasks +[![](https://img.shields.io/npm/dw/@webiny/api-elasticsearch-tasks.svg)](https://www.npmjs.com/package/@webiny/api-elasticsearch-tasks) +[![](https://img.shields.io/npm/v/@webiny/api-elasticsearch-tasks.svg)](https://www.npmjs.com/package/@webiny/api-elasticsearch-tasks) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +## Install +``` +yarn add @webiny/api-elasticsearch-tasks +``` diff --git a/packages/api-elasticsearch-tasks/__tests__/helpers/helpers.ts b/packages/api-elasticsearch-tasks/__tests__/helpers/helpers.ts new file mode 100644 index 00000000000..c8770544676 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/helpers/helpers.ts @@ -0,0 +1,72 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; + +export interface PermissionsArg { + name: string; + locales?: string[]; + rwd?: string; + pw?: string; + own?: boolean; +} + +export const identity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +const getSecurityIdentity = () => { + return identity; +}; + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "task.entry", + rwd: "rwd" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + }, + { + name: "*" + } + ]; +}; + +export const createIdentity = (identity?: SecurityIdentity) => { + if (!identity) { + return getSecurityIdentity(); + } + return identity; +}; + +export const createDummyLocales = () => { + return new ContextPlugin(async context => { + const { i18n, security } = context; + + await security.authenticate(""); + + await security.withoutAuthorization(async () => { + const [items] = await i18n.locales.listLocales({ + where: {} + }); + if (items.length > 0) { + return; + } + await i18n.locales.createLocale({ + code: "en-US", + default: true + }); + await i18n.locales.createLocale({ + code: "de-DE", + default: true + }); + }); + }); +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts b/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts new file mode 100644 index 00000000000..c9d365787a0 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts @@ -0,0 +1,69 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy"; +import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security"; +import { + SecurityIdentity, + SecurityPermission, + SecurityStorageOperations +} from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import { Context } from "@webiny/tasks/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; + +interface Config { + setupGraphQL?: boolean; + permissions: SecurityPermission[]; + identity?: SecurityIdentity | null; +} + +export const defaultIdentity: SecurityIdentity = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ + setupGraphQL, + permissions, + identity +}: Config): Plugin[] => { + const tenancyStorage = getStorageOps("tenancy"); + const securityStorage = getStorageOps("security"); + + return [ + createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), + setupGraphQL ? createTenancyGraphQL() : null, + createSecurityContext({ storageOperations: securityStorage.storageOperations }), + setupGraphQL ? createSecurityGraphQL() : null, + new ContextPlugin(context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }), + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/helpers/useHandler.ts b/packages/api-elasticsearch-tasks/__tests__/helpers/useHandler.ts new file mode 100644 index 00000000000..1de38dc6520 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/helpers/useHandler.ts @@ -0,0 +1,88 @@ +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { createDummyLocales, createIdentity, createPermissions } from "./helpers"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import { createRawEventHandler, createRawHandler } from "@webiny/handler-aws"; +import { PluginCollection } from "@webiny/plugins/types"; +import { createBackgroundTaskContext } from "@webiny/tasks"; +import { createHandler } from "@webiny/tasks/handler"; +import { ITaskEvent } from "@webiny/tasks/handler/types"; +import { LambdaContext } from "@webiny/handler-aws/types"; +import { Context } from "~/types"; +import { createElasticsearchBackgroundTasks } from "~/index"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import dbPlugins from "@webiny/handler-db"; +import { DynamoDbDriver } from "@webiny/db-dynamodb"; + +export interface UseHandlerParams { + plugins?: PluginCollection; +} + +export const useHandler = (params?: UseHandlerParams) => { + const { plugins: initialPlugins = [] } = params || {}; + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + const documentClient = getDocumentClient(); + // const elasticsearchClient = createElasticsearchClient(); + + const plugins = [ + [ + dbPlugins({ + table: process.env.DB_TABLE, + driver: new DynamoDbDriver({ + documentClient + }) + }), + createWcpContext(), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: false, + permissions: createPermissions(), + identity: createIdentity() + }), + i18nContext(), + i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + graphQLHandlerPlugins(), + ...createBackgroundTaskContext(), + createRawEventHandler(async ({ context }) => { + return context; + }), + ...createElasticsearchBackgroundTasks({ + documentClient: getDocumentClient() + }), + ...initialPlugins + ] + ]; + + const handle = createHandler({ + plugins + }); + + const rawHandler = createRawHandler({ + plugins + }); + + return { + handle: (event: ITaskEvent, context?: Partial) => { + return handle(event, { + getRemainingTimeInMillis: () => 1000000, + ...context + } as LambdaContext); + }, + rawHandle: async () => { + return await rawHandler({}, {} as LambdaContext); + } + }; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/context.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/context.ts new file mode 100644 index 00000000000..6e463b5d3c0 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/context.ts @@ -0,0 +1,33 @@ +import { PluginsContainer } from "@webiny/plugins"; +import { PartialDeep } from "type-fest"; +import { createMockIdentity } from "~tests/mocks/identity"; +import { Context, ITaskUpdateData, IUpdateTaskResponse } from "@webiny/tasks/types"; +import { ElasticsearchContext } from "@webiny/api-elasticsearch/types"; + +export const createContextMock = ( + params?: PartialDeep +): Context & ElasticsearchContext => { + return { + ...params, + plugins: params?.plugins || new PluginsContainer(), + tasks: { + updateTask: async ( + id: string, + data: Required + ): Promise => { + return { + ...data, + id, + startedOn: new Date().toISOString(), + finishedOn: undefined, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + definitionId: "myCustomTaskDefinition", + createdBy: createMockIdentity(), + eventResponse: {} as any + }; + }, + ...params?.tasks + } + } as unknown as Context & ElasticsearchContext; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/elasticsearch.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/elasticsearch.ts new file mode 100644 index 00000000000..d9f937f4b81 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/elasticsearch.ts @@ -0,0 +1,74 @@ +import { Client } from "@webiny/api-elasticsearch"; +import { IElasticsearchIndexingTaskValuesSettings } from "~/types"; + +interface GetIndexSettingsParams { + index: string; +} + +interface PutIndexSettingsParams { + index: string; + body: { + index: { + number_of_replicas: number; + refresh_interval: string; + }; + }; +} + +export const indexSettings: IElasticsearchIndexingTaskValuesSettings = { + authors: { + numberOfReplicas: 1, + refreshInterval: "1s" + }, + articles: { + numberOfReplicas: 2, + refreshInterval: "2s" + }, + categories: { + numberOfReplicas: 3, + refreshInterval: "3s" + } +}; + +export interface ExtendedClient extends Client { + enabled: Set; + disabled: Set; +} + +export const createElasticsearchClientMock = (): ExtendedClient => { + const enabled = new Set(); + const disabled = new Set(); + return { + enabled, + disabled, + indices: { + getSettings: async (params: GetIndexSettingsParams) => { + if (indexSettings[params.index]) { + const settings = indexSettings[params.index]; + return { + body: { + [params.index]: { + settings: { + index: { + number_of_replicas: settings.numberOfReplicas, + refresh_interval: settings.refreshInterval + } + } + } + } + }; + } + return null; + }, + putSettings: async (params: PutIndexSettingsParams) => { + if (params.body.index.refresh_interval === "-1") { + disabled.add(params.index); + enabled.delete(params.index); + return; + } + disabled.delete(params.index); + enabled.add(params.index); + } + } + } as unknown as ExtendedClient; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/event.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/event.ts new file mode 100644 index 00000000000..d2a6249e936 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/event.ts @@ -0,0 +1,14 @@ +import { ITaskEvent } from "@webiny/tasks/handler/types"; + +export const createMockEvent = (event?: Partial): ITaskEvent => { + return { + webinyTaskId: "mockEventId", + webinyTaskDefinitionId: "mockDefinitionId", + executionName: "someExecutionName", + tenant: "root", + locale: "en-US", + endpoint: "manage", + stateMachineId: "randomMachineId", + ...event + }; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/identity.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/identity.ts new file mode 100644 index 00000000000..565b864f49a --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/identity.ts @@ -0,0 +1,9 @@ +import { ITaskIdentity } from "@webiny/tasks/types"; + +export const createMockIdentity = (): ITaskIdentity => { + return { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/indexManager.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/indexManager.ts new file mode 100644 index 00000000000..9d81270a707 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/indexManager.ts @@ -0,0 +1,16 @@ +import { IndexManager } from "~/settings"; +import { Client } from "@webiny/api-elasticsearch"; +import { IElasticsearchIndexingTaskValuesSettings } from "~/types"; +import { createElasticsearchClientMock } from "~tests/mocks/elasticsearch"; + +interface Params { + client?: Client; + settings?: IElasticsearchIndexingTaskValuesSettings; +} + +export const createIndexManagerMock = (params?: Params) => { + return new IndexManager( + params?.client || createElasticsearchClientMock(), + params?.settings || {} + ); +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/store.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/store.ts new file mode 100644 index 00000000000..e6d5aed56d9 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/store.ts @@ -0,0 +1,14 @@ +import { TaskManagerStore } from "@webiny/tasks/runner/TaskManagerStore"; +import { Context, ITaskData } from "@webiny/tasks/types"; +import { createTaskMock } from "~tests/mocks/task"; +import { createContextMock } from "~tests/mocks/context"; + +interface Params { + context?: Context; + task?: ITaskData; +} +export const createTaskManagerStoreMock = (params?: Params) => { + const context = params?.context || createContextMock(); + const task = params?.task || createTaskMock(); + return new TaskManagerStore(context, task); +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/mocks/task.ts b/packages/api-elasticsearch-tasks/__tests__/mocks/task.ts new file mode 100644 index 00000000000..6a76166c654 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/mocks/task.ts @@ -0,0 +1,18 @@ +import { createMockIdentity } from "./identity"; +import { ITaskData, TaskDataStatus } from "@webiny/tasks/types"; + +export const createTaskMock = (task?: Partial): ITaskData => { + return { + id: "myCustomTaskDataId", + definitionId: "myCustomTaskDefinition", + input: {}, + name: "A custom task defined via method", + log: [], + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + taskStatus: TaskDataStatus.PENDING, + createdBy: createMockIdentity(), + eventResponse: undefined, + ...task + }; +}; diff --git a/packages/api-elasticsearch-tasks/__tests__/settings/indexManager.test.ts b/packages/api-elasticsearch-tasks/__tests__/settings/indexManager.test.ts new file mode 100644 index 00000000000..554ccaafd4e --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/settings/indexManager.test.ts @@ -0,0 +1,47 @@ +import { IndexManager } from "~/settings"; +import { createElasticsearchClientMock, indexSettings } from "~tests/mocks/elasticsearch"; + +describe("index manager", () => { + it("should construct index manager", () => { + const client = createElasticsearchClientMock(); + const manager = new IndexManager(client, structuredClone(indexSettings)); + + expect(manager.settings).toEqual(indexSettings); + }); + + it("should disable indexing", async () => { + const client = createElasticsearchClientMock(); + const manager = new IndexManager(client, structuredClone({})); + + expect(manager.settings).toEqual({}); + + const settings = await manager.disableIndexing("authors"); + + expect(settings).toEqual(indexSettings.authors); + expect(client.disabled.has("authors")).toBeTruthy(); + expect(client.enabled.has("authors")).toBeFalsy(); + }); + + it("should enable indexing", async () => { + const client = createElasticsearchClientMock(); + const manager = new IndexManager( + client, + structuredClone({ + authors: indexSettings.authors + }) + ); + + expect(manager.settings).toEqual({ + authors: indexSettings.authors + }); + + await manager.enableIndexing("authors"); + + expect(manager.settings).toEqual({ + authors: indexSettings.authors + }); + + expect(client.disabled.has("authors")).toBeFalsy(); + expect(client.enabled.has("authors")).toBeTruthy(); + }); +}); diff --git a/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexing.test.ts b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexing.test.ts new file mode 100644 index 00000000000..47375ba35d0 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexing.test.ts @@ -0,0 +1,172 @@ +/** + * Tests in this file will use real data and Elasticsearch instance. + */ +import { useHandler } from "~tests/helpers/useHandler"; +import { ResponseContinueResult, ResponseDoneResult } from "@webiny/tasks/response"; +import { ITaskEvent } from "@webiny/tasks/handler/types"; +import { TaskDataStatus } from "@webiny/tasks/types"; + +const createContextTaskAndEvent = async (handler: ReturnType) => { + const context = await handler.rawHandle(); + + const task = await context.tasks.createTask({ + name: "Run reindexing to test it", + definitionId: "elasticsearchReindexing", + input: { + /** + * We do not actually want to reindex anything, so we will use a non-existing index. + */ + matching: "non-existing-index-for-testing" + } + }); + + const event: ITaskEvent = { + webinyTaskId: task.id, + webinyTaskDefinitionId: task.definitionId, + executionName: "someExecutionName", + tenant: "root", + locale: "en-US", + stateMachineId: "someStateMachineId", + endpoint: "manage" + }; + + return { + context, + task, + event + }; +}; + +describe("reindexing", () => { + it("should return a done response - no items at all to reindex", async () => { + const handler = useHandler({}); + + const { context, task, event } = await createContextTaskAndEvent(handler); + + const result = await handler.handle(event); + expect(result).toBeInstanceOf(ResponseDoneResult); + expect(result).toEqual( + new ResponseDoneResult({ + webinyTaskId: task.id, + webinyTaskDefinitionId: task.definitionId, + tenant: "root", + locale: "en-US", + message: "No more items to process - no last evaluated keys." + }) + ); + + const updatedTask = await context.tasks.getTask(task.id); + + expect(updatedTask).toEqual({ + ...task, + input: { + ...task.input, + settings: {} + }, + executionName: "someExecutionName", + savedOn: expect.toBeDateString(), + startedOn: expect.toBeDateString(), + finishedOn: expect.toBeDateString(), + taskStatus: TaskDataStatus.SUCCESS, + iterations: 1 + // log: [ + // { + // message: "Task started.", + // createdOn: expect.toBeDateString() + // }, + // { + // createdOn: expect.toBeDateString(), + // message: "No more items to process - no last evaluated keys." + // } + // ] + }); + }); + + it("should return a continue response - mock lambda timeout", async () => { + const handler = useHandler({}); + + const { context, task, event } = await createContextTaskAndEvent(handler); + + const result = await handler.handle(event, { + getRemainingTimeInMillis: () => 100 + }); + + expect(result).toEqual( + new ResponseContinueResult({ + webinyTaskId: task.id, + webinyTaskDefinitionId: task.definitionId, + tenant: "root", + locale: "en-US", + input: {} + }) + ); + + const updatedTask = await context.tasks.getTask(task.id); + + expect(updatedTask).toEqual({ + ...task, + executionName: "someExecutionName", + savedOn: expect.toBeDateString(), + startedOn: expect.toBeDateString(), + finishedOn: undefined, + taskStatus: TaskDataStatus.RUNNING, + iterations: 1 + // log: [ + // { + // message: "Task started.", + // createdOn: expect.toBeDateString() + // }, + // { + // createdOn: expect.toBeDateString(), + // message: "Task continuing.", + // input: {} + // } + // ] + }); + /** + * Should end the task when there are no more items to process. + */ + const resultAfterContinue = await handler.handle(event); + + expect(resultAfterContinue).toEqual( + new ResponseDoneResult({ + webinyTaskId: task.id, + webinyTaskDefinitionId: task.definitionId, + tenant: "root", + locale: "en-US", + message: "No more items to process - no last evaluated keys." + }) + ); + + const updatedTaskAfterContinue = await context.tasks.getTask(task.id); + + expect(updatedTaskAfterContinue).toEqual({ + ...task, + input: { + ...task.input, + settings: {} + }, + executionName: "someExecutionName", + savedOn: expect.toBeDateString(), + startedOn: expect.toBeDateString(), + finishedOn: expect.toBeDateString(), + taskStatus: TaskDataStatus.SUCCESS, + iterations: 2 + // log: [ + // { + // message: "Task started.", + // createdOn: expect.toBeDateString() + // }, + // { + // createdOn: expect.toBeDateString(), + // message: "Task continuing.", + // input: {} + // }, + // { + // createdOn: expect.toBeDateString(), + // message: "No more items to process - no last evaluated keys." + // } + // ] + }); + }); +}); diff --git a/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskDefinition.test.ts b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskDefinition.test.ts new file mode 100644 index 00000000000..fb4b43fb6a5 --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskDefinition.test.ts @@ -0,0 +1,10 @@ +import { createElasticsearchReindexingTask } from "~/tasks"; + +describe("reindexing task definition", () => { + it("should return a task definition", async () => { + const task = createElasticsearchReindexingTask(); + expect(task.id).toEqual("elasticsearchReindexing"); + expect(task.title).toEqual("Elasticsearch reindexing"); + expect(task.run).toEqual(expect.any(Function)); + }); +}); diff --git a/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskRunner.test.ts b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskRunner.test.ts new file mode 100644 index 00000000000..4b8aceeb64b --- /dev/null +++ b/packages/api-elasticsearch-tasks/__tests__/tasks/reindexing/reindexingTaskRunner.test.ts @@ -0,0 +1,121 @@ +import { ReindexingTaskRunner } from "~/tasks/reindexing/ReindexingTaskRunner"; +import { Manager } from "~/tasks/Manager"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import { + Response, + ResponseAbortedResult, + ResponseContinueResult, + ResponseDoneResult, + TaskResponse +} from "@webiny/tasks/response"; +import { createMockEvent } from "~tests/mocks/event"; +import { createTaskManagerStoreMock } from "~tests/mocks/store"; +import { createContextMock } from "~tests/mocks/context"; +import { createIndexManagerMock } from "~tests/mocks/indexManager"; +import { createElasticsearchClientMock } from "~tests/mocks/elasticsearch"; + +describe("reindexing task runner", () => { + it("should run a task and receive a continue response", async () => { + const manager = new Manager({ + context: createContextMock(), + store: createTaskManagerStoreMock(), + response: new TaskResponse(new Response(createMockEvent())), + documentClient: getDocumentClient(), + elasticsearchClient: {} as any, + isCloseToTimeout: () => { + return true; + }, + isAborted: () => { + return false; + } + }); + const indexManager = createIndexManagerMock(); + const runner = new ReindexingTaskRunner(manager, indexManager); + + const result = await runner.exec( + { + PK: "my-pk", + SK: "my-sk" + }, + 100 + ); + expect(result).toEqual( + new ResponseContinueResult({ + webinyTaskId: "mockEventId", + webinyTaskDefinitionId: "mockDefinitionId", + tenant: "root", + locale: "en-US", + input: { + keys: { + PK: "my-pk", + SK: "my-sk" + } + } + }) + ); + }); + + it("should run and receive an abort response", async () => { + const manager = new Manager({ + context: createContextMock(), + store: createTaskManagerStoreMock(), + response: new TaskResponse(new Response(createMockEvent())), + documentClient: getDocumentClient(), + elasticsearchClient: createElasticsearchClientMock(), + isCloseToTimeout: () => { + return false; + }, + isAborted: () => { + return true; + } + }); + const indexManager = createIndexManagerMock(); + const runner = new ReindexingTaskRunner(manager, indexManager); + + const result = await runner.exec( + { + PK: "my-pk", + SK: "my-sk" + }, + 100 + ); + expect(result).toEqual( + new ResponseAbortedResult({ + webinyTaskId: "mockEventId", + webinyTaskDefinitionId: "mockDefinitionId", + tenant: "root", + locale: "en-US" + }) + ); + }); + + it("should run and receive a done response - no items to process", async () => { + const manager = new Manager({ + context: createContextMock(), + store: createTaskManagerStoreMock(), + response: new TaskResponse(new Response(createMockEvent())), + documentClient: getDocumentClient(), + elasticsearchClient: createElasticsearchClientMock(), + isCloseToTimeout: () => { + return false; + }, + isAborted: () => { + return false; + } + }); + const indexManager = createIndexManagerMock(); + const runner = new ReindexingTaskRunner(manager, indexManager); + + const result = await runner.exec(); + + expect(result).toEqual( + new ResponseDoneResult({ + message: "No more items to process.", + webinyTaskId: "mockEventId", + webinyTaskDefinitionId: "mockDefinitionId", + tenant: "root", + locale: "en-US" + }) + ); + }); +}); diff --git a/packages/api-elasticsearch-tasks/jest.setup.js b/packages/api-elasticsearch-tasks/jest.setup.js new file mode 100644 index 00000000000..049095053ae --- /dev/null +++ b/packages/api-elasticsearch-tasks/jest.setup.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const presets = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-i18n", "storage-operations"], + ["@webiny/api-security", "storage-operations"], + ["@webiny/api-tenancy", "storage-operations"] +); + +module.exports = { + ...base({ path: __dirname }, presets) +}; diff --git a/packages/api-elasticsearch-tasks/package.json b/packages/api-elasticsearch-tasks/package.json new file mode 100644 index 00000000000..f6bd0d2d90c --- /dev/null +++ b/packages/api-elasticsearch-tasks/package.json @@ -0,0 +1,54 @@ +{ + "name": "@webiny/api-elasticsearch-tasks", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Tasks", + "contributors": [ + "Bruno Zorić " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.6", + "@webiny/api-elasticsearch": "0.0.0", + "@webiny/aws-sdk": "0.0.0", + "@webiny/db-dynamodb": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/tasks": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.22.6", + "@babel/core": "^7.22.8", + "@babel/preset-env": "^7.22.7", + "@babel/preset-typescript": "^7.22.5", + "@webiny/api": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/api-i18n": "0.0.0", + "@webiny/api-security": "0.0.0", + "@webiny/api-tenancy": "0.0.0", + "@webiny/api-wcp": "0.0.0", + "@webiny/cli": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/handler-db": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.13", + "type-fest": "^2.19.0", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f" +} diff --git a/packages/api-elasticsearch-tasks/src/definitions/entry.ts b/packages/api-elasticsearch-tasks/src/definitions/entry.ts new file mode 100644 index 00000000000..b985c523455 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/definitions/entry.ts @@ -0,0 +1,30 @@ +import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; + +interface Params { + table: Table; + entityName: string; +} + +export const createEntry = (params: Params): Entity => { + const { table, entityName } = params; + return new Entity({ + name: entityName, + table, + attributes: { + PK: { + type: "string", + partitionKey: true + }, + SK: { + type: "string", + sortKey: true + }, + index: { + type: "string" + }, + data: { + type: "map" + } + } + }); +}; diff --git a/packages/api-elasticsearch-tasks/src/definitions/index.ts b/packages/api-elasticsearch-tasks/src/definitions/index.ts new file mode 100644 index 00000000000..7cb150082b8 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/definitions/index.ts @@ -0,0 +1,2 @@ +export { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; +export * from "./table"; diff --git a/packages/api-elasticsearch-tasks/src/definitions/table.ts b/packages/api-elasticsearch-tasks/src/definitions/table.ts new file mode 100644 index 00000000000..797452d2bfe --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/definitions/table.ts @@ -0,0 +1,19 @@ +import { DynamoDBClient } from "@webiny/aws-sdk/client-dynamodb"; +import { Table, TableConstructor } from "@webiny/db-dynamodb/toolbox"; + +interface Params { + documentClient: DynamoDBClient; +} + +export const createTable = ({ documentClient }: Params): Table => { + const config: TableConstructor = { + name: process.env.DB_TABLE_ELASTICSEARCH as string, + partitionKey: "PK", + sortKey: "SK", + DocumentClient: documentClient, + autoExecute: true, + autoParse: true + }; + + return new Table(config); +}; diff --git a/packages/api-elasticsearch-tasks/src/errors/IndexSettingsGetError.ts b/packages/api-elasticsearch-tasks/src/errors/IndexSettingsGetError.ts new file mode 100644 index 00000000000..b0cf125447d --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/errors/IndexSettingsGetError.ts @@ -0,0 +1,14 @@ +import WebinyError from "@webiny/error"; +import { AugmentedError } from "~/types"; + +export class IndexSettingsGetError extends WebinyError { + public readonly index: string; + + public constructor(error: AugmentedError, index: string) { + super(error.message, "GET_INDEX_SETTINGS_ERROR", { + ...error.data, + index + }); + this.index = index; + } +} diff --git a/packages/api-elasticsearch-tasks/src/errors/IndexSettingsSetError.ts b/packages/api-elasticsearch-tasks/src/errors/IndexSettingsSetError.ts new file mode 100644 index 00000000000..642af7b7b6e --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/errors/IndexSettingsSetError.ts @@ -0,0 +1,14 @@ +import WebinyError from "@webiny/error"; +import { AugmentedError } from "~/types"; + +export class IndexSettingsSetError extends WebinyError { + public readonly index: string; + + public constructor(error: AugmentedError, index: string) { + super(error.message, "SET_INDEX_SETTINGS_ERROR", { + ...error.data, + index + }); + this.index = index; + } +} diff --git a/packages/api-elasticsearch-tasks/src/errors/IndexingDisableError.ts b/packages/api-elasticsearch-tasks/src/errors/IndexingDisableError.ts new file mode 100644 index 00000000000..b2d3f2597b2 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/errors/IndexingDisableError.ts @@ -0,0 +1 @@ +export class IndexingDisableError extends Error {} diff --git a/packages/api-elasticsearch-tasks/src/errors/IndexingEnableError.ts b/packages/api-elasticsearch-tasks/src/errors/IndexingEnableError.ts new file mode 100644 index 00000000000..8b00569c8ad --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/errors/IndexingEnableError.ts @@ -0,0 +1 @@ +export class IndexingEnableError extends Error {} diff --git a/packages/api-elasticsearch-tasks/src/errors/index.ts b/packages/api-elasticsearch-tasks/src/errors/index.ts new file mode 100644 index 00000000000..98f28e7f3a8 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/errors/index.ts @@ -0,0 +1,4 @@ +export * from "./IndexingDisableError"; +export * from "./IndexingEnableError"; +export * from "./IndexSettingsGetError"; +export * from "./IndexSettingsSetError"; diff --git a/packages/api-elasticsearch-tasks/src/helpers/scan.ts b/packages/api-elasticsearch-tasks/src/helpers/scan.ts new file mode 100644 index 00000000000..1f5d4141dbf --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/helpers/scan.ts @@ -0,0 +1,21 @@ +import { scan as tableScan, ScanOptions } from "@webiny/db-dynamodb"; +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { IElasticsearchIndexingTaskValuesKeys } from "~/types"; + +interface Params { + table: Table; + keys?: IElasticsearchIndexingTaskValuesKeys; + options?: Pick; +} + +export const scan = async (params: Params) => { + const { table, keys } = params; + return tableScan({ + table, + options: { + startKey: keys, + limit: 200, + ...params.options + } + }); +}; diff --git a/packages/api-elasticsearch-tasks/src/index.ts b/packages/api-elasticsearch-tasks/src/index.ts new file mode 100644 index 00000000000..05d4f963b24 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/index.ts @@ -0,0 +1,10 @@ +import { CreateElasticsearchIndexTaskConfig, createElasticsearchReindexingTask } from "~/tasks"; +import { Plugin } from "@webiny/plugins/types"; + +export type CreateElasticsearchBackgroundTasksParams = CreateElasticsearchIndexTaskConfig; + +export const createElasticsearchBackgroundTasks = ( + params?: CreateElasticsearchBackgroundTasksParams +): Plugin[] => { + return [createElasticsearchReindexingTask(params)]; +}; diff --git a/packages/api-elasticsearch-tasks/src/settings/DisableIndexing.ts b/packages/api-elasticsearch-tasks/src/settings/DisableIndexing.ts new file mode 100644 index 00000000000..f85321ba989 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/DisableIndexing.ts @@ -0,0 +1,26 @@ +import { IndexingDisableError } from "~/errors"; +import { IIndexSettingsValues } from "~/types"; +import { IndexSettingsManager } from "./IndexSettingsManager"; + +export class DisableIndexing { + private readonly settings: IndexSettingsManager; + + public constructor(settings: IndexSettingsManager) { + this.settings = settings; + } + + public async exec(index: string): Promise { + const settings = await this.settings.getSettings(index); + + try { + await this.settings.setSettings(index, { + numberOfReplicas: 0, + refreshInterval: "-1" + }); + } catch (ex) { + throw new IndexingDisableError(ex); + } + + return settings; + } +} diff --git a/packages/api-elasticsearch-tasks/src/settings/EnableIndexing.ts b/packages/api-elasticsearch-tasks/src/settings/EnableIndexing.ts new file mode 100644 index 00000000000..fab9a768dee --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/EnableIndexing.ts @@ -0,0 +1,21 @@ +import { IndexingEnableError } from "~/errors"; +import { IIndexSettingsValues } from "~/types"; +import { IndexSettingsManager } from "./IndexSettingsManager"; + +export class EnableIndexing { + private readonly settings: IndexSettingsManager; + + public constructor(settings: IndexSettingsManager) { + this.settings = settings; + } + + public async exec(index: string, settings: IIndexSettingsValues): Promise { + try { + await this.settings.setSettings(index, { + ...settings + }); + } catch (ex) { + throw new IndexingEnableError(ex); + } + } +} diff --git a/packages/api-elasticsearch-tasks/src/settings/IndexManager.ts b/packages/api-elasticsearch-tasks/src/settings/IndexManager.ts new file mode 100644 index 00000000000..50f685f7a8b --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/IndexManager.ts @@ -0,0 +1,52 @@ +import { IndexSettingsManager } from "~/settings/IndexSettingsManager"; +import { DisableIndexing } from "./DisableIndexing"; +import { EnableIndexing } from "./EnableIndexing"; +import { IElasticsearchIndexingTaskValuesSettings } from "~/types"; +import { IIndexManager } from "~/settings/types"; +import { Client } from "@webiny/api-elasticsearch"; + +const defaultIndexSettings = { + numberOfReplicas: 1, + refreshInterval: "1s" +}; + +export class IndexManager implements IIndexManager { + private readonly disable: DisableIndexing; + private readonly enable: EnableIndexing; + private readonly _settings: IElasticsearchIndexingTaskValuesSettings; + + public get settings(): IElasticsearchIndexingTaskValuesSettings { + return this._settings; + } + + public constructor(client: Client, settings: IElasticsearchIndexingTaskValuesSettings) { + const indexSettings = new IndexSettingsManager(client); + this.disable = new DisableIndexing(indexSettings); + this.enable = new EnableIndexing(indexSettings); + this._settings = settings; + } + + public async disableIndexing(index: string) { + /** + * No need to disable indexing if it's already disabled. + */ + if (this._settings[index]) { + return this._settings[index]; + } + const settings = await this.disable.exec(index); + this._settings[index] = settings; + return settings; + } + + public async enableIndexing(index?: string) { + if (!index) { + const indexes = Object.keys(this._settings); + for (const index of indexes) { + await this.enableIndexing(index); + } + return; + } + const settings = this._settings[index] || defaultIndexSettings; + await this.enable.exec(index, settings); + } +} diff --git a/packages/api-elasticsearch-tasks/src/settings/IndexSettingsManager.ts b/packages/api-elasticsearch-tasks/src/settings/IndexSettingsManager.ts new file mode 100644 index 00000000000..d0d1cfc14b2 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/IndexSettingsManager.ts @@ -0,0 +1,44 @@ +import { Client } from "@webiny/api-elasticsearch"; +import { IndexSettingsGetError, IndexSettingsSetError } from "~/errors"; +import { IIndexSettingsValues } from "~/types"; + +export class IndexSettingsManager { + private readonly elasticsearch: Client; + + public constructor(elasticsearch: Client) { + this.elasticsearch = elasticsearch; + } + + public async getSettings(index: string): Promise { + try { + const response = await this.elasticsearch.indices.getSettings({ + index + }); + + const setting = response.body[index].settings.index; + + return { + numberOfReplicas: setting.number_of_replicas, + refreshInterval: setting.refresh_interval + }; + } catch (ex) { + throw new IndexSettingsGetError(ex, index); + } + } + + public async setSettings(index: string, settings: IIndexSettingsValues): Promise { + try { + await this.elasticsearch.indices.putSettings({ + index, + body: { + index: { + number_of_replicas: settings.numberOfReplicas, + refresh_interval: settings.refreshInterval + } + } + }); + } catch (ex) { + throw new IndexSettingsSetError(ex, index); + } + } +} diff --git a/packages/api-elasticsearch-tasks/src/settings/index.ts b/packages/api-elasticsearch-tasks/src/settings/index.ts new file mode 100644 index 00000000000..73178e8960c --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/index.ts @@ -0,0 +1 @@ +export * from "./IndexManager"; diff --git a/packages/api-elasticsearch-tasks/src/settings/types.ts b/packages/api-elasticsearch-tasks/src/settings/types.ts new file mode 100644 index 00000000000..e40a76ff8df --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/settings/types.ts @@ -0,0 +1,8 @@ +import { IElasticsearchIndexingTaskValuesSettings, IIndexSettingsValues } from "~/types"; + +export interface IIndexManager { + settings: IElasticsearchIndexingTaskValuesSettings; + + disableIndexing(index: string): Promise; + enableIndexing(index?: string): Promise; +} diff --git a/packages/api-elasticsearch-tasks/src/tasks/Manager.ts b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts new file mode 100644 index 00000000000..01ab435f5c6 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts @@ -0,0 +1,83 @@ +import { DynamoDBDocument, getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; +import { Client, createElasticsearchClient } from "@webiny/api-elasticsearch"; +import { createTable } from "~/definitions"; +import { Context, IElasticsearchIndexingTaskValues, IManager } from "~/types"; +import { createEntry } from "~/definitions/entry"; +import { Entity } from "@webiny/db-dynamodb/toolbox"; +import { ITaskResponse } from "@webiny/tasks/response/abstractions"; +import { ITaskManagerStore } from "@webiny/tasks/runner/abstractions"; +import { + batchReadAll, + BatchReadItem, + batchWriteAll, + BatchWriteItem, + BatchWriteResult +} from "@webiny/db-dynamodb"; + +export interface ManagerParams { + context: Context; + documentClient?: DynamoDBDocument; + elasticsearchClient?: Client; + isCloseToTimeout: () => boolean; + isAborted: () => boolean; + response: ITaskResponse; + store: ITaskManagerStore; +} + +export class Manager implements IManager { + public readonly documentClient: DynamoDBDocument; + public readonly elasticsearch: Client; + public readonly context: Context; + public readonly table: ReturnType; + public readonly isCloseToTimeout: () => boolean; + public readonly isAborted: () => boolean; + public readonly response: ITaskResponse; + public readonly store: ITaskManagerStore; + + private readonly entities: Record> = {}; + + public constructor(params: ManagerParams) { + this.context = params.context; + this.documentClient = params?.documentClient || getDocumentClient(); + + this.elasticsearch = + params?.elasticsearchClient || + params.context.elasticsearch || + createElasticsearchClient({ + endpoint: `https://${process.env.ELASTIC_SEARCH_ENDPOINT}` + }); + + this.table = createTable({ + documentClient: this.documentClient + }); + this.isCloseToTimeout = params.isCloseToTimeout; + this.isAborted = params.isAborted; + this.response = params.response; + this.store = params.store; + } + + public getEntity(name: string): Entity { + if (this.entities[name]) { + return this.entities[name]; + } + + return (this.entities[name] = createEntry({ + table: this.table, + entityName: name + })); + } + + public async read(items: BatchReadItem[]) { + return await batchReadAll({ + table: this.table, + items + }); + } + + public async write(items: BatchWriteItem[]): Promise { + return await batchWriteAll({ + table: this.table, + items + }); + } +} diff --git a/packages/api-elasticsearch-tasks/src/tasks/index.ts b/packages/api-elasticsearch-tasks/src/tasks/index.ts new file mode 100644 index 00000000000..bbf7a64ebce --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/tasks/index.ts @@ -0,0 +1 @@ +export * from "./reindexing"; diff --git a/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts new file mode 100644 index 00000000000..2979b509a4c --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts @@ -0,0 +1,143 @@ +import { + IDynamoDbElasticsearchRecord, + IElasticsearchIndexingTaskValuesKeys, + IManager +} from "~/types"; +import { ITaskResponse, ITaskResponseResult } from "@webiny/tasks/response/abstractions"; +import { scan } from "~/helpers/scan"; +import { BatchWriteItem, ScanResponse } from "@webiny/db-dynamodb"; +import { IndexManager } from "~/settings"; +import { IIndexManager } from "~/settings/types"; + +const getKeys = (results: ScanResponse): IElasticsearchIndexingTaskValuesKeys | undefined => { + if (results.lastEvaluatedKey?.PK && results.lastEvaluatedKey?.SK) { + return { + PK: results.lastEvaluatedKey.PK as unknown as string, + SK: results.lastEvaluatedKey.SK as unknown as string + }; + } + return undefined; +}; + +export class ReindexingTaskRunner { + private readonly manager: IManager; + private keys?: IElasticsearchIndexingTaskValuesKeys; + + private readonly indexManager: IIndexManager; + private readonly response: ITaskResponse; + + public constructor(manager: IManager, indexManager: IndexManager) { + this.manager = manager; + this.response = manager.response; + this.indexManager = indexManager; + } + + /** + * When running the task, we always must check: + * * if task is close to timeout + * * if task was aborted + */ + public async exec( + keys: IElasticsearchIndexingTaskValuesKeys | undefined = undefined, + limit = 200 + ): Promise { + this.keys = keys; + + const isIndexAllowed = (index: string): boolean => { + const input = this.manager.store.getInput(); + if (typeof input.matching !== "string" || !input.matching) { + return true; + } + return index.includes(input.matching); + }; + + try { + while (this.manager.isCloseToTimeout() === false) { + if (this.manager.isAborted()) { + return this.response.aborted(); + } + + const results = await scan({ + table: this.manager.table, + keys: this.keys, + options: { + limit + } + }); + if (results.items.length === 0) { + await this.indexManager.enableIndexing(); + return this.response.done("No more items to process."); + } + + const batch: BatchWriteItem[] = []; + for (const item of results.items) { + /** + * No index defined? Impossible but let's skip if really happens. + */ + if (!item.index) { + continue; + } + if (isIndexAllowed(item.index) === false) { + continue; + } + /** + * Is there a possibility that entityName does not exist? What do we do at that point? + */ + const entityName = item._et || item.entity; + /** + * Let's skip for now. + */ + if (!entityName) { + continue; + } + const entity = this.manager.getEntity(entityName); + /** + * Disable the indexing for the current index. + * Method does nothing if indexing is already disabled. + */ + await this.indexManager.disableIndexing(item.index); + /** + * Reindexing will be triggered by the `putBatch` method. + */ + batch.push( + entity.putBatch({ + ...item, + modified: new Date().toISOString() + }) + ); + } + await this.manager.write(batch); + /** + * We always store the index settings, so we can restore them later. + * Also, we always want to store what was the last key we processed, just in case something breaks, so we can continue from this point. + */ + this.keys = getKeys(results); + await this.manager.store.updateInput({ + settings: this.indexManager.settings, + keys: this.keys + }); + /** + * We want to make sure that, if there are no last evaluated keys, we enable indexing. + */ + if (!this.keys) { + await this.indexManager.enableIndexing(); + return this.response.done("No more items to process - no last evaluated keys."); + } + } + return this.response.continue({ + keys: this.keys + }); + } catch (ex) { + /** + * We want to enable indexing if there was an error. + */ + try { + await this.indexManager.enableIndexing(); + } catch (er) { + er.data = ex; + return this.response.error(er); + } + return this.response.error(ex); + } + } +} diff --git a/packages/api-elasticsearch-tasks/src/tasks/reindexing/index.ts b/packages/api-elasticsearch-tasks/src/tasks/reindexing/index.ts new file mode 100644 index 00000000000..f73cdbbd661 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/tasks/reindexing/index.ts @@ -0,0 +1 @@ +export * from "./reindexingTaskDefinition"; diff --git a/packages/api-elasticsearch-tasks/src/tasks/reindexing/reindexingTaskDefinition.ts b/packages/api-elasticsearch-tasks/src/tasks/reindexing/reindexingTaskDefinition.ts new file mode 100644 index 00000000000..9ce3c8209a9 --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/tasks/reindexing/reindexingTaskDefinition.ts @@ -0,0 +1,37 @@ +import { createTaskDefinition } from "@webiny/tasks"; +import { Context, IElasticsearchIndexingTaskValues } from "~/types"; +import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import { Client } from "@webiny/api-elasticsearch"; + +export interface CreateElasticsearchIndexTaskConfig { + documentClient?: DynamoDBDocument; + elasticsearchClient?: Client; +} + +export const createElasticsearchReindexingTask = (params?: CreateElasticsearchIndexTaskConfig) => { + return createTaskDefinition({ + id: "elasticsearchReindexing", + title: "Elasticsearch reindexing", + run: async ({ context, isCloseToTimeout, response, input, isAborted, store }) => { + const { Manager } = await import("../Manager"); + const { IndexManager } = await import("~/settings"); + const { ReindexingTaskRunner } = await import("./ReindexingTaskRunner"); + + const manager = new Manager({ + elasticsearchClient: params?.elasticsearchClient, + documentClient: params?.documentClient, + response, + context, + isAborted, + isCloseToTimeout, + store + }); + + const indexManager = new IndexManager(manager.elasticsearch, input.settings || {}); + const reindexing = new ReindexingTaskRunner(manager, indexManager); + + const keys = input.keys || undefined; + return reindexing.exec(keys, 200); + } + }); +}; diff --git a/packages/api-elasticsearch-tasks/src/types.ts b/packages/api-elasticsearch-tasks/src/types.ts new file mode 100644 index 00000000000..106a328e30e --- /dev/null +++ b/packages/api-elasticsearch-tasks/src/types.ts @@ -0,0 +1,61 @@ +import { ElasticsearchContext } from "@webiny/api-elasticsearch/types"; +import { Entity } from "@webiny/db-dynamodb/toolbox"; +import { Context as TasksContext } from "@webiny/tasks/types"; +import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import { Client } from "@webiny/api-elasticsearch"; +import { createTable } from "~/definitions"; +import { ITaskResponse } from "@webiny/tasks/response/abstractions"; +import { ITaskManagerStore } from "@webiny/tasks/runner/abstractions"; +import { BatchWriteItem, BatchWriteResult } from "@webiny/db-dynamodb"; + +export interface Context extends ElasticsearchContext, TasksContext {} + +export interface IElasticsearchIndexingTaskValuesKeys { + PK: string; + SK: string; +} + +export interface IIndexSettingsValues { + numberOfReplicas: number; + refreshInterval: string; +} + +export interface IElasticsearchIndexingTaskValuesSettings { + [key: string]: IIndexSettingsValues; +} + +export interface IElasticsearchIndexingTaskValues { + matching?: string; + keys?: IElasticsearchIndexingTaskValuesKeys; + settings?: IElasticsearchIndexingTaskValuesSettings; +} + +export interface AugmentedError extends Error { + data?: Record; + [key: string]: any; +} + +export interface IDynamoDbElasticsearchRecord { + PK: string; + SK: string; + index: string; + _et?: string; + entity: string; + data: Record; + modified: string; +} + +export interface IManager { + readonly documentClient: DynamoDBDocument; + readonly elasticsearch: Client; + readonly context: Context; + readonly table: ReturnType; + readonly isCloseToTimeout: () => boolean; + readonly isAborted: () => boolean; + readonly response: ITaskResponse; + readonly store: ITaskManagerStore; + + getEntity: (name: string) => Entity; + + write: (items: BatchWriteItem[]) => Promise; +} diff --git a/packages/api-elasticsearch-tasks/tsconfig.build.json b/packages/api-elasticsearch-tasks/tsconfig.build.json new file mode 100644 index 00000000000..7350cbfe2e3 --- /dev/null +++ b/packages/api-elasticsearch-tasks/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api-elasticsearch/tsconfig.build.json" }, + { "path": "../aws-sdk/tsconfig.build.json" }, + { "path": "../db-dynamodb/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" }, + { "path": "../api/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-security/tsconfig.build.json" }, + { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../api-wcp/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../handler-db/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-elasticsearch-tasks/tsconfig.json b/packages/api-elasticsearch-tasks/tsconfig.json new file mode 100644 index 00000000000..2f79def0dee --- /dev/null +++ b/packages/api-elasticsearch-tasks/tsconfig.json @@ -0,0 +1,64 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api-elasticsearch" }, + { "path": "../aws-sdk" }, + { "path": "../db-dynamodb" }, + { "path": "../error" }, + { "path": "../tasks" }, + { "path": "../api" }, + { "path": "../api-headless-cms" }, + { "path": "../api-i18n" }, + { "path": "../api-security" }, + { "path": "../api-tenancy" }, + { "path": "../api-wcp" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../handler-db" }, + { "path": "../handler-graphql" }, + { "path": "../plugins" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api-elasticsearch/*": ["../api-elasticsearch/src/*"], + "@webiny/api-elasticsearch": ["../api-elasticsearch/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], + "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], + "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"], + "@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/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-security/*": ["../api-security/src/*"], + "@webiny/api-security": ["../api-security/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/api-wcp/*": ["../api-wcp/src/*"], + "@webiny/api-wcp": ["../api-wcp/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-db/*": ["../handler-db/src/*"], + "@webiny/handler-db": ["../handler-db/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-elasticsearch-tasks/webiny.config.js b/packages/api-elasticsearch-tasks/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-elasticsearch-tasks/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/api-elasticsearch/src/client.ts b/packages/api-elasticsearch/src/client.ts index 79c7bfe2ecd..ef8ba161140 100644 --- a/packages/api-elasticsearch/src/client.ts +++ b/packages/api-elasticsearch/src/client.ts @@ -16,6 +16,8 @@ const createClientKey = (options: ElasticsearchClientOptions) => { return hash.digest("hex"); }; +export { Client, ClientOptions }; + export const createElasticsearchClient = (options: ElasticsearchClientOptions): Client => { const key = createClientKey(options); const existing = clients.get(key); diff --git a/packages/api-file-manager-s3/package.json b/packages/api-file-manager-s3/package.json index b22ebaf3b52..586c9d16be6 100644 --- a/packages/api-file-manager-s3/package.json +++ b/packages/api-file-manager-s3/package.json @@ -15,16 +15,20 @@ "@webiny/api-security": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/handler": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", + "@webiny/tasks": "0.0.0", "@webiny/utils": "0.0.0", "@webiny/validation": "0.0.0", "form-data": "^4.0.0", "mime": "^3.0.0", "node-fetch": "^2.6.1", + "object-hash": "^3.0.0", "p-map": "4.0.0", "p-reduce": "2.1.0", - "sanitize-filename": "^1.6.3" + "sanitize-filename": "^1.6.3", + "sharp": "0.32.6" }, "devDependencies": { "@babel/cli": "^7.22.6", diff --git a/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts new file mode 100644 index 00000000000..a2cafb0c368 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/assetDeliveryConfig.ts @@ -0,0 +1,46 @@ +import { + createAssetDelivery as createBaseAssetDelivery, + createAssetDeliveryConfig +} from "@webiny/api-file-manager"; +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { S3AssetResolver } from "~/assetDelivery/s3/S3AssetResolver"; +import { S3OutputStrategy } from "~/assetDelivery/s3/S3OutputStrategy"; +import { SharpTransform } from "~/assetDelivery/s3/SharpTransform"; + +export type AssetDeliveryParams = Parameters[0] & { + imageResizeWidths?: number[]; + presignedUrlTtl?: number; +}; + +export const assetDeliveryConfig = (params: AssetDeliveryParams) => { + const bucket = process.env.S3_BUCKET as string; + const region = process.env.AWS_REGION as string; + + const { + presignedUrlTtl = 900, + imageResizeWidths = [100, 300, 500, 750, 1000, 1500, 2500], + ...baseParams + } = params; + + return [ + // Base asset delivery + createBaseAssetDelivery(baseParams), + // S3 plugins + createAssetDeliveryConfig(config => { + const s3 = new S3({ region }); + + config.decorateAssetResolver(() => { + // This resolver loads file information from the `.metadata` file. + return new S3AssetResolver(s3, bucket); + }); + + config.decorateAssetOutputStrategy(() => { + return new S3OutputStrategy(s3, bucket, presignedUrlTtl); + }); + + config.decorateAssetTransformationStrategy(() => { + return new SharpTransform({ s3, bucket, imageResizeWidths }); + }); + }) + ]; +}; diff --git a/packages/api-file-manager-s3/src/assetDelivery/createAssetDelivery.ts b/packages/api-file-manager-s3/src/assetDelivery/createAssetDelivery.ts new file mode 100644 index 00000000000..10753358b48 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/createAssetDelivery.ts @@ -0,0 +1,14 @@ +import { createAssetDeliveryPluginLoader } from "@webiny/api-file-manager"; +import { PluginFactory } from "@webiny/plugins/types"; +import type { AssetDeliveryParams } from "./assetDeliveryConfig"; + +export const createAssetDelivery = (params: AssetDeliveryParams): PluginFactory => { + /** + * We only want to load this plugin in the context of the Asset Delivery Lambda function. + */ + return createAssetDeliveryPluginLoader(() => { + return import(/* webpackChunkName: "s3AssetDelivery" */ "./assetDeliveryConfig").then( + ({ assetDeliveryConfig }) => assetDeliveryConfig(params) + ); + }); +}; diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts new file mode 100644 index 00000000000..f0676791f27 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts @@ -0,0 +1,44 @@ +import type { S3 } from "@webiny/aws-sdk/client-s3"; + +interface AssetMetadata { + id: string; + tenant: string; + locale: string; + size: number; + contentType: string; +} + +export class S3AssetMetadataReader { + private readonly s3: S3; + private readonly bucket: string; + + constructor(s3: S3, bucket: string) { + this.bucket = bucket; + this.s3 = s3; + } + + async getMetadata(key: string): Promise { + const metadataKey = `${key}.metadata`; + + console.log("Reading metadata", metadataKey); + + const { Body } = await this.s3.getObject({ + Bucket: this.bucket, + Key: metadataKey + }); + + if (!Body) { + throw Error(`Missing or corrupted ${metadataKey} file!`); + } + + const metadata = JSON.parse(await Body.transformToString()); + + return { + id: metadata.id, + tenant: metadata.tenant, + locale: metadata.locale, + size: metadata.size, + contentType: metadata.contentType + }; + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts new file mode 100644 index 00000000000..87669b369fd --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts @@ -0,0 +1,37 @@ +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { Asset, AssetRequest, AssetResolver } from "@webiny/api-file-manager"; +import { S3AssetMetadataReader } from "./S3AssetMetadataReader"; +import { S3ContentsReader } from "./S3ContentsReader"; + +export class S3AssetResolver implements AssetResolver { + private readonly s3: S3; + private readonly bucket: string; + + constructor(s3: S3, bucket: string) { + this.s3 = s3; + this.bucket = bucket; + } + + async resolve(request: AssetRequest): Promise { + try { + const metadataReader = new S3AssetMetadataReader(this.s3, this.bucket); + const metadata = await metadataReader.getMetadata(request.getKey()); + + const asset = new Asset({ + id: metadata.id, + tenant: metadata.tenant, + locale: metadata.locale, + size: metadata.size, + contentType: metadata.contentType, + key: request.getKey() + }); + + asset.setContentsReader(new S3ContentsReader(this.s3, this.bucket)); + + return asset; + } catch (error) { + console.error(error); + return undefined; + } + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3ContentsReader.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3ContentsReader.ts new file mode 100644 index 00000000000..0f7d07d43bd --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3ContentsReader.ts @@ -0,0 +1,25 @@ +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { Asset, AssetContentsReader } from "@webiny/api-file-manager"; + +export class S3ContentsReader implements AssetContentsReader { + private s3: S3; + private readonly bucket: string; + + constructor(s3: S3, bucket: string) { + this.s3 = s3; + this.bucket = bucket; + } + + async read(asset: Asset): Promise { + const { Body } = await this.s3.getObject({ + Bucket: this.bucket, + Key: asset.getKey() + }); + + if (!Body) { + throw Error(`Unable to read ${asset.getKey()}!`); + } + + return Buffer.from(await Body.transformToByteArray()); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3ErrorAssetReply.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3ErrorAssetReply.ts new file mode 100644 index 00000000000..8b942c8df0a --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3ErrorAssetReply.ts @@ -0,0 +1,10 @@ +import { AssetReply } from "@webiny/api-file-manager"; + +export class S3ErrorAssetReply extends AssetReply { + constructor(message: string) { + super({ + code: 400, + body: () => ({ error: message }) + }); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts new file mode 100644 index 00000000000..ea574101aed --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3OutputStrategy.ts @@ -0,0 +1,44 @@ +import { Asset, AssetOutputStrategy, AssetReply } from "@webiny/api-file-manager"; +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. + */ +export class S3OutputStrategy implements AssetOutputStrategy { + private readonly s3: S3; + private readonly bucket: string; + private readonly presignedUrlTtl: number; + + constructor(s3: S3, bucket: string, presignedUrlTtl: number) { + this.presignedUrlTtl = presignedUrlTtl; + this.s3 = s3; + this.bucket = bucket; + } + + async output(asset: Asset): Promise { + if ((await asset.getSize()) > MAX_RETURN_CONTENT_LENGTH) { + return new S3RedirectAssetReply( + await this.getPresignedUrl(asset), + this.presignedUrlTtl + ); + } + + return new S3StreamAssetReply(asset); + } + + protected getPresignedUrl(asset: Asset) { + return getSignedUrl( + this.s3, + new GetObjectCommand({ + Bucket: this.bucket, + Key: asset.getKey() + }), + { expiresIn: this.presignedUrlTtl } + ); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3RedirectAssetReply.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3RedirectAssetReply.ts new file mode 100644 index 00000000000..676bb88c68f --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3RedirectAssetReply.ts @@ -0,0 +1,15 @@ +import { AssetReply } from "@webiny/api-file-manager"; +import { ResponseHeaders } from "@webiny/handler"; + +export class S3RedirectAssetReply extends AssetReply { + constructor(url: string, cacheDuration: number) { + super({ + code: 301, + headers: ResponseHeaders.create({ + location: url, + "cache-control": "public, max-age=" + cacheDuration + }), + body: () => "" + }); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3StreamAssetReply.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3StreamAssetReply.ts new file mode 100644 index 00000000000..01b4636b129 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3StreamAssetReply.ts @@ -0,0 +1,14 @@ +import { Asset, AssetReply } from "@webiny/api-file-manager"; +import { ResponseHeaders } from "@webiny/handler"; + +export class S3StreamAssetReply extends AssetReply { + constructor(asset: Asset) { + super({ + code: 200, + headers: ResponseHeaders.create({ + "content-type": asset.getContentType() + }), + body: () => asset.getContents() + }); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts new file mode 100644 index 00000000000..cb616787810 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/SharpTransform.ts @@ -0,0 +1,165 @@ +import sharp from "sharp"; +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { + Asset, + AssetRequest, + AssetRequestOptions, + AssetTransformationStrategy +} from "@webiny/api-file-manager"; +import { WidthCollection } from "./transformation/WidthCollection"; +import * as utils from "./transformation/utils"; +import { CallableContentsReader } from "./transformation/CallableContentsReader"; +import { AssetKeyGenerator } from "./transformation/AssetKeyGenerator"; + +interface SharpTransformationParams { + s3: S3; + bucket: string; + imageResizeWidths: number[]; +} + +export class SharpTransform implements AssetTransformationStrategy { + private readonly params: SharpTransformationParams; + + constructor(params: SharpTransformationParams) { + this.params = params; + } + + async transform(assetRequest: AssetRequest, asset: Asset): Promise { + if (!utils.SUPPORTED_TRANSFORMABLE_IMAGES.includes(asset.getExtension())) { + return asset; + } + + // `original` is part of the request, but it won't even get to this point in the execution. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { original, ...options } = assetRequest.getOptions(); + + const transformedAsset = asset.clone(); + + if (Object.keys(options).length > 0) { + // Transformations were requested. + return this.transformAsset(transformedAsset, options); + } + + // Return an optimized asset. + return this.optimizeAsset(transformedAsset); + } + + private async transformAsset(asset: Asset, options: Omit) { + if (options.width) { + const { s3, bucket } = this.params; + + const assetKey = new AssetKeyGenerator(asset); + const transformedAssetKey = assetKey.getTransformedImageKey(options); + + try { + const { Body } = await s3.getObject({ + Bucket: bucket, + Key: transformedAssetKey + }); + + if (!Body) { + throw new Error(`Missing image body!`); + } + + const buffer = Buffer.from(await Body.transformToByteArray()); + + asset.setContentsReader(new CallableContentsReader(() => buffer)); + } catch (e) { + const optimizedImage = await this.optimizeAsset(asset); + + const widths = new WidthCollection(this.params.imageResizeWidths); + const width = widths.getClosestOrMax(options.width); + + /** + * `width` is the only transformation we currently support. + */ + const buffer = await optimizedImage.getContents(); + const transformedBuffer = sharp(buffer, { animated: this.isAssetAnimated(asset) }) + .resize({ width }) + .toBuffer(); + + /** + * Transformations are applied to the optimized image. + */ + asset.setContentsReader(new CallableContentsReader(() => transformedBuffer)); + + await s3.putObject({ + Bucket: bucket, + Key: transformedAssetKey, + ContentType: asset.getContentType(), + Body: await asset.getContents() + }); + } + } + + return asset; + } + + private async optimizeAsset(asset: Asset) { + const { s3, bucket } = this.params; + + const assetKey = new AssetKeyGenerator(asset); + const optimizedAssetKey = assetKey.getOptimizedImageKey(); + + try { + const { Body } = await s3.getObject({ + Bucket: bucket, + Key: optimizedAssetKey + }); + + if (!Body) { + throw new Error(`Missing image body!`); + } + + const buffer = Buffer.from(await Body.transformToByteArray()); + + asset.setContentsReader(new CallableContentsReader(() => buffer)); + } catch (e) { + // If not found, create an optimized version of the original asset. + const buffer = await asset.getContents(); + + const optimizationMap: Record sharp.Sharp) | undefined> = { + "image/png": (buffer: Buffer) => this.optimizePng(buffer), + "image/jpeg": (buffer: Buffer) => this.optimizeJpeg(buffer), + "image/jpg": (buffer: Buffer) => this.optimizeJpeg(buffer) + }; + + const optimization = optimizationMap[asset.getContentType()]; + + if (!optimization) { + console.log(`no optimizations defined for ${asset.getContentType()}`); + return asset; + } + + const optimizedBuffer = optimization(buffer).toBuffer(); + + asset.setContentsReader(new CallableContentsReader(() => optimizedBuffer)); + + await s3.putObject({ + Bucket: bucket, + Key: optimizedAssetKey, + ContentType: asset.getContentType(), + Body: await asset.getContents() + }); + } + + return asset; + } + + private isAssetAnimated(asset: Asset) { + return ["gif", "webp"].includes(asset.getExtension()); + } + + private optimizePng(buffer: Buffer) { + return sharp(buffer) + .resize({ width: 2560, withoutEnlargement: true, fit: "inside" }) + .png({ compressionLevel: 9, adaptiveFiltering: true, force: true }) + .withMetadata(); + } + + private optimizeJpeg(buffer: Buffer) { + return sharp(buffer) + .resize({ width: 2560, withoutEnlargement: true, fit: "inside" }) + .toFormat("jpeg", { quality: 90 }); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/AssetKeyGenerator.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/AssetKeyGenerator.ts new file mode 100644 index 00000000000..a1f1bc1e8a9 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/AssetKeyGenerator.ts @@ -0,0 +1,20 @@ +import { Asset } from "@webiny/api-file-manager"; +import * as newUtils from "./utils"; +import * as legacyUtils from "./legacyUtils"; + +export class AssetKeyGenerator { + private utils: typeof newUtils; + private asset: Asset; + + constructor(asset: Asset) { + this.asset = asset; + this.utils = asset.getKey().includes("/") ? newUtils : legacyUtils; + } + + getOptimizedImageKey() { + return this.utils.getImageKey({ key: this.asset.getKey() }); + } + getTransformedImageKey(transformations: Record) { + return this.utils.getImageKey({ key: this.asset.getKey(), transformations }); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/CallableContentsReader.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/CallableContentsReader.ts new file mode 100644 index 00000000000..395f2e38e15 --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/CallableContentsReader.ts @@ -0,0 +1,17 @@ +import { AssetContentsReader } from "@webiny/api-file-manager"; + +interface ContentsCallable { + (): Promise | Buffer; +} + +export class CallableContentsReader implements AssetContentsReader { + private readonly callable: ContentsCallable; + + constructor(callable: ContentsCallable) { + this.callable = callable; + } + + async read(): Promise { + return this.callable(); + } +} diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/WidthCollection.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/WidthCollection.ts new file mode 100644 index 00000000000..9925499bbfb --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/WidthCollection.ts @@ -0,0 +1,23 @@ +export class WidthCollection { + private readonly values: number[]; + + constructor(values: number[]) { + this.values = values.sort((a, b) => a - b); + } + + max() { + return Math.max(...this.values); + } + + min() { + return Math.min(...this.values); + } + + getClosestOrMax(value: number | undefined): number { + if (!value) { + return this.max(); + } + const gteGivenValue = this.values.filter(w => w >= value); + return gteGivenValue.length > 0 ? gteGivenValue[0] : this.max(); + } +} diff --git a/packages/api-file-manager/src/handlers/transform/legacyUtils.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/legacyUtils.ts similarity index 87% rename from packages/api-file-manager/src/handlers/transform/legacyUtils.ts rename to packages/api-file-manager-s3/src/assetDelivery/s3/transformation/legacyUtils.ts index 52ce4834058..94458eb6f99 100644 --- a/packages/api-file-manager/src/handlers/transform/legacyUtils.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/legacyUtils.ts @@ -1,9 +1,3 @@ -/** - * BACKWARDS-COMPATIBILITY! - * - * This file contains utilities for files that don't have `id/key` structure, meaning, all files created before 5.35.0 release. - */ - import objectHash from "object-hash"; const SUPPORTED_IMAGES = [".jpg", ".jpeg", ".png", ".svg", ".gif"]; diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/utils.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/utils.ts new file mode 100644 index 00000000000..1760c4108dc --- /dev/null +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/transformation/utils.ts @@ -0,0 +1,40 @@ +import objectHash from "object-hash"; + +const SUPPORTED_TRANSFORMABLE_IMAGES = ["jpg", "jpeg", "png", "webp"]; +const OPTIMIZED_TRANSFORMED_IMAGE_PREFIX = "img-o-t-"; +const OPTIMIZED_IMAGE_PREFIX = "img-o-"; + +const getOptimizedImageKeyPrefix = (key: string): string => { + const [id, name] = key.split("/"); + return `${id}/${OPTIMIZED_IMAGE_PREFIX}${name}`; +}; + +const getOptimizedTransformedImageKeyPrefix = ( + key: string, + transformationsHash: string +): string => { + const [id, name] = key.split("/"); + return `${id}/${OPTIMIZED_TRANSFORMED_IMAGE_PREFIX}${transformationsHash}-${name}`; +}; + +interface GetImageKeyParams { + key: string; + transformations?: any; +} + +const getImageKey = ({ key, transformations }: GetImageKeyParams): string => { + if (!transformations) { + return getOptimizedImageKeyPrefix(key); + } + + return getOptimizedTransformedImageKeyPrefix(key, objectHash(transformations)); +}; + +export { + SUPPORTED_TRANSFORMABLE_IMAGES, + OPTIMIZED_TRANSFORMED_IMAGE_PREFIX, + OPTIMIZED_IMAGE_PREFIX, + getImageKey, + getOptimizedImageKeyPrefix, + getOptimizedTransformedImageKeyPrefix +}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts b/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts new file mode 100644 index 00000000000..baf73937f99 --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts @@ -0,0 +1,7 @@ +import { File } from "@webiny/api-file-manager/types"; + +export class CdnPathsGenerator { + generate(file: File) { + return [`/files/${file.key}*`, `/private/${file.key}*`, ...file.aliases]; + } +} diff --git a/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts b/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts new file mode 100644 index 00000000000..d0d31ec584d --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts @@ -0,0 +1,97 @@ +import { ServiceDiscovery } from "@webiny/api"; +import { CloudFront } from "@webiny/aws-sdk/client-cloudfront"; +import { ITaskRunParams } from "@webiny/tasks/types"; +import { FileManagerContext } from "@webiny/api-file-manager/types"; +import { executeWithRetry } from "@webiny/utils"; +import { ITaskResponseResult } from "@webiny/tasks/response/abstractions"; + +class ReturnContinue extends Error {} + +export interface InvalidateCacheInput { + /** + * Caller of the task (e.g., `fm-before-update`, `fm-after-delete`). + */ + caller: string; + /** + * Cache paths to invalidate. + */ + paths: string[]; +} + +export class InvalidateCloudfrontCacheTask { + private continueIfCode = ["TooManyInvalidationsInProgress", "Throttling"]; + + public async run({ + input, + response, + isCloseToTimeout + }: ITaskRunParams): Promise { + const manifest = await ServiceDiscovery.load(); + + if (!manifest) { + return response.error({ + message: `Unable to invalidate cache due to a missing service manifest.`, + code: "MISSING_SERVICE_MANIFEST", + data: { + manifest: "api" + } + }); + } + + const { distributionId } = manifest.api.cloudfront; + + const invalidateCache = () => { + return this.invalidateCache(input.caller, distributionId as string, input.paths); + }; + + try { + await executeWithRetry(invalidateCache, { + minTimeout: 2000, + forever: true, + onFailedAttempt: err => { + if (this.continueIfCode.includes(err.name)) { + throw new ReturnContinue(); + } + + if (err.message.includes("not authorized to perform")) { + throw err; + } + + if (isCloseToTimeout()) { + throw new ReturnContinue(); + } + } + }); + } catch (error) { + if (error instanceof ReturnContinue) { + return response.continue(input); + } + + return response.error({ + message: error.message, + code: "EXECUTE_WITH_RETRY_FAILED", + data: input.paths + }); + } + + return response.done(); + } + + private async invalidateCache( + caller: string, + distributionId: string, + paths: string[] + ): Promise { + const cloudfront = new CloudFront(); + await cloudfront.createInvalidation({ + DistributionId: distributionId, + InvalidationBatch: { + CallerReference: `${new Date().getTime()}-${caller}`, + Paths: { + Quantity: paths.length, + Items: paths + } + } + }); + } +} diff --git a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts new file mode 100644 index 00000000000..5e1b907b3cf --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts @@ -0,0 +1,30 @@ +import { ContextPlugin } from "@webiny/api"; +import { FileManagerContext, OnFileBeforeUpdateTopicParams } from "@webiny/api-file-manager/types"; +import { CdnPathsGenerator } from "~/flushCdnCache/CdnPathsGenerator"; + +class FlushCacheOnFileDelete { + private readonly context: FileManagerContext; + private readonly pathsGenerator: CdnPathsGenerator; + + constructor(context: FileManagerContext) { + this.pathsGenerator = new CdnPathsGenerator(); + this.context = context; + context.fileManager.onFileAfterDelete.subscribe(this.onFileAfterDelete); + } + + private onFileAfterDelete = async ({ file }: OnFileBeforeUpdateTopicParams) => { + await this.context.tasks.trigger({ + definition: "cloudfrontInvalidateCache", + input: { + caller: "fm-before-delete", + paths: this.pathsGenerator.generate(file) + } + }); + }; +} + +export const flushCacheOnFileDelete = () => { + return new ContextPlugin(context => { + new FlushCacheOnFileDelete(context); + }); +}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts new file mode 100644 index 00000000000..ec2306a112a --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts @@ -0,0 +1,37 @@ +import { ContextPlugin } from "@webiny/api"; +import { FileManagerContext, OnFileBeforeUpdateTopicParams } from "@webiny/api-file-manager/types"; +import { CdnPathsGenerator } from "~/flushCdnCache/CdnPathsGenerator"; + +class FlushCacheOnFileUpdate { + private readonly context: FileManagerContext; + private readonly pathsGenerator: CdnPathsGenerator; + + constructor(context: FileManagerContext) { + this.pathsGenerator = new CdnPathsGenerator(); + this.context = context; + context.fileManager.onFileBeforeUpdate.subscribe(this.onFileBeforeUpdate); + } + + private onFileBeforeUpdate = async ({ file, original }: OnFileBeforeUpdateTopicParams) => { + const prevAccessControl = original.accessControl; + const newAccessControl = file.accessControl; + + if (prevAccessControl?.type === newAccessControl?.type) { + return; + } + + await this.context.tasks.trigger({ + definition: "cloudfrontInvalidateCache", + input: { + caller: "fm-before-update", + paths: this.pathsGenerator.generate(file) + } + }); + }; +} + +export const flushCacheOnFileUpdate = () => { + return new ContextPlugin(context => { + new FlushCacheOnFileUpdate(context); + }); +}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/index.ts b/packages/api-file-manager-s3/src/flushCdnCache/index.ts new file mode 100644 index 00000000000..7bef76c809b --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/index.ts @@ -0,0 +1,7 @@ +import { flushCacheOnFileUpdate } from "~/flushCdnCache/flushCacheOnFileUpdate"; +import { flushCacheOnFileDelete } from "~/flushCdnCache/flushCacheOnFileDelete"; +import { createInvalidateCacheTask } from "./invalidateCacheTaskDefinition"; + +export const flushCdnCache = () => { + return [flushCacheOnFileUpdate(), flushCacheOnFileDelete(), createInvalidateCacheTask()]; +}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts b/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts new file mode 100644 index 00000000000..e6c1b9394c9 --- /dev/null +++ b/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts @@ -0,0 +1,15 @@ +import { createTaskDefinition } from "@webiny/tasks"; +import { FileManagerContext } from "@webiny/api-file-manager/types"; +import { InvalidateCloudfrontCacheTask } from "./InvalidateCacheTask"; + +export const createInvalidateCacheTask = () => { + return createTaskDefinition({ + id: "cloudfrontInvalidateCache", + title: "Invalidate Cloudfront Cache", + description: "A task to invalidate Cloudfront cache by given paths.", + run(params) { + const taskRunner = new InvalidateCloudfrontCacheTask(); + return taskRunner.run(params); + } + }); +}; diff --git a/packages/api-file-manager-s3/src/index.ts b/packages/api-file-manager-s3/src/index.ts index 6720d3055d1..ac825a30f12 100644 --- a/packages/api-file-manager-s3/src/index.ts +++ b/packages/api-file-manager-s3/src/index.ts @@ -1,6 +1,9 @@ import graphqlFileStorageS3 from "./plugins/graphqlFileStorageS3"; import fileStorageS3 from "./plugins/fileStorageS3"; - -export default () => [fileStorageS3(), graphqlFileStorageS3]; +import { addFileMetadata } from "./plugins/addFileMetadata"; +import { flushCdnCache } from "~/flushCdnCache"; export { createFileUploadModifier } from "./utils/FileUploadModifier"; +export { createAssetDelivery } from "./assetDelivery/createAssetDelivery"; + +export default () => [fileStorageS3(), graphqlFileStorageS3, addFileMetadata(), flushCdnCache()]; diff --git a/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts b/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts new file mode 100644 index 00000000000..b3d4db6470f --- /dev/null +++ b/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts @@ -0,0 +1,27 @@ +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { ContextPlugin } from "@webiny/api"; +import { FileManagerContext } from "@webiny/api-file-manager/types"; + +export const addFileMetadata = () => { + return new ContextPlugin(context => { + context.fileManager.onFileAfterCreate.subscribe(async ({ file }) => { + const metadata = { + id: file.id, + tenant: file.tenant, + locale: file.locale, + size: file.size, + contentType: file.type + }; + + const s3 = new S3({ region: process.env.AWS_REGION }); + + await s3.putObject({ + Bucket: String(process.env.S3_BUCKET), + Key: `${file.key}.metadata`, + Body: JSON.stringify(metadata), + ContentType: "application/json", + CacheControl: "max-age=31536000" + }); + }); + }); +}; diff --git a/packages/api-file-manager-s3/tsconfig.build.json b/packages/api-file-manager-s3/tsconfig.build.json index d86d15979fd..51f9ea5dcc8 100644 --- a/packages/api-file-manager-s3/tsconfig.build.json +++ b/packages/api-file-manager-s3/tsconfig.build.json @@ -7,8 +7,10 @@ { "path": "../api-security/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, { "path": "../validation/tsconfig.build.json" } ], diff --git a/packages/api-file-manager-s3/tsconfig.json b/packages/api-file-manager-s3/tsconfig.json index db7bfcfe4d1..4d1e8f364c5 100644 --- a/packages/api-file-manager-s3/tsconfig.json +++ b/packages/api-file-manager-s3/tsconfig.json @@ -7,8 +7,10 @@ { "path": "../api-security" }, { "path": "../aws-sdk" }, { "path": "../error" }, + { "path": "../handler" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, + { "path": "../tasks" }, { "path": "../utils" }, { "path": "../validation" } ], @@ -29,10 +31,14 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"], "@webiny/utils/*": ["../utils/src/*"], "@webiny/utils": ["../utils/src"], "@webiny/validation/*": ["../validation/src/*"], diff --git a/packages/api-file-manager/__tests__/handlers/download.disabled.ts b/packages/api-file-manager/__tests__/handlers/download.disabled.ts new file mode 100644 index 00000000000..6b9323e852d --- /dev/null +++ b/packages/api-file-manager/__tests__/handlers/download.disabled.ts @@ -0,0 +1,243 @@ +// TODO: temporarily disabling this test. It requires a refactor to use the graphql lambda handler. +// import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +// import { createHandler } from "@webiny/handler-aws"; +// import useGqlHandler from "~tests/utils/useGqlHandler"; +// import { fileAData } from "~tests/mocks/files"; +// import { +// APIGatewayEvent, +// APIGatewayEventRequestContextWithAuthorizer +// } from "@webiny/handler-aws/types"; +// +// const binaryMimeTypes: string[] = []; +// binaryMimeTypes.indexOf = () => { +// return 1; +// }; +// +// enum Files { +// smallFile = "small-test-file-path.png", +// largeFile = "large-test-file-path.png" +// } +// +// jest.mock("@webiny/aws-sdk/client-s3", () => { +// return { +// S3: class { +// private _fileSizes = { +// ["small-test-file-path.png"]: 137, +// ["large-test-file-path.png"]: 500000001 +// }; +// +// async headObject(obj: any) { +// const { Key: file } = obj; +// return { +// ContentLength: this._fileSizes[file as Files] +// }; +// } +// +// async getObject(obj: any) { +// const { Key: file } = obj; +// return { +// Body: file, +// ContentType: "image/png", +// ContentLength: this._fileSizes[file as Files] || file.length +// }; +// } +// }, +// +// getSignedUrl() { +// return "https://presigned-domain.loc/some-url?1fjdsfjdsfds"; +// }, +// GetObjectCommand: class {} +// }; +// }); +// +// jest.mock("~/handlers/transform/loaders", () => { +// return []; +// }); +// +// const createFileDownloadEvent = (path: string): APIGatewayEvent => { +// return { +// path, +// body: "", +// multiValueQueryStringParameters: null, +// httpMethod: "GET", +// headers: {}, +// pathParameters: { +// path: path.includes("/files/") ? path.replace("/files/", "") : path +// }, +// queryStringParameters: null, +// isBase64Encoded: false, +// requestContext: {} as APIGatewayEventRequestContextWithAuthorizer, +// resource: "", +// multiValueHeaders: {}, +// stageVariables: null +// }; +// }; +// +// const createDownloadHandler = (params: Parameters[0]) => { +// const handler = createHandler(params); +// return (event: APIGatewayEvent, context: any = {}) => { +// return handler(event, context); +// }; +// }; +// +// describe("download handler", () => { +// beforeEach(() => { +// process.env.S3_BUCKET = "some-bucket"; +// process.env.AWS_REGION = "eu-central-1"; +// }); +// +// it("should trigger s3 file download - stream", async () => { +// const handler = createDownloadHandler({ +// plugins: [createDownloadFileByExactKeyPlugins()] +// }); +// +// const result = await handler(createFileDownloadEvent(`/files/${Files.smallFile}`)); +// +// expect(result).toEqual({ +// body: "c21hbGwtdGVzdC1maWxlLXBhdGgucG5n", +// isBase64Encoded: true, +// statusCode: 200, +// headers: { +// "access-control-allow-headers": "*", +// "access-control-allow-methods": "GET,HEAD", +// "access-control-allow-origin": "*", +// "cache-control": "public, max-age=30758400", +// connection: "keep-alive", +// "content-length": "24", +// "content-type": "image/png", +// date: expect.any(String), +// "x-webiny-base64-encoded": "true" +// } +// }); +// }); +// +// it("should trigger s3 file download via a file alias", async () => { +// const documentClient = getDocumentClient(); +// +// // TODO: ideally, this should be configured as storage ops, to abstract tests away from implementation details, +// // TODO: like DynamoDB document client, handler setups, etc. +// const handler = createDownloadHandler({ +// plugins: [createDownloadFileByAliasPlugins({ documentClient })] +// }); +// +// const createAliases = ["/test-file.png", "/my/custom/path.jpeg"]; +// const updateAliases = ["/test-file.png", "/test-file-2.png"]; +// +// const fileWithAliases = { +// ...fileAData, +// aliases: createAliases +// }; +// +// const fileBody = Buffer.from(fileWithAliases.key).toString("base64"); +// const contentLength = fileWithAliases.key.length.toString(); +// +// const { createFile, updateFile, deleteFile } = useGqlHandler(); +// +// // Create a file and make sure it's accessible via all provided aliases. +// const [result] = await createFile({ data: fileWithAliases }); +// +// expect(result).toMatchObject({ +// data: { +// fileManager: { +// createFile: { +// data: { +// aliases: ["/test-file.png", "/my/custom/path.jpeg"] +// }, +// error: null +// } +// } +// } +// }); +// +// const resultA = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); +// +// expect(resultA).toEqual({ +// body: fileBody, +// isBase64Encoded: true, +// statusCode: 200, +// headers: { +// "access-control-allow-headers": "*", +// "access-control-allow-methods": "GET,HEAD", +// "access-control-allow-origin": "*", +// "cache-control": "public, max-age=30758400", +// connection: "keep-alive", +// "content-length": contentLength, +// "content-type": "image/png", +// date: expect.any(String), +// "x-webiny-base64-encoded": "true" +// } +// }); +// +// const resultB = await handler(createFileDownloadEvent(fileWithAliases.aliases[1])); +// +// expect(resultB).toEqual({ +// body: fileBody, +// isBase64Encoded: true, +// statusCode: 200, +// headers: { +// "access-control-allow-headers": "*", +// "access-control-allow-methods": "GET,HEAD", +// "access-control-allow-origin": "*", +// "cache-control": "public, max-age=30758400", +// connection: "keep-alive", +// "content-length": contentLength, +// "content-type": "image/png", +// date: expect.any(String), +// "x-webiny-base64-encoded": "true" +// } +// }); +// +// // Update aliases, and make sure file is not accessible via old aliases. +// await updateFile({ +// id: fileWithAliases.id, +// data: { +// aliases: updateAliases +// } +// }); +// +// // First alias should still be accessible. +// const resultC = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); +// +// expect(resultC.statusCode).toEqual(200); +// +// // Second original alias should not be accessible. +// const resultD = await handler(createFileDownloadEvent(fileWithAliases.aliases[1])); +// expect(resultD.statusCode).toEqual(404); +// +// // Second updated alias should be accessible. +// const resultE = await handler(createFileDownloadEvent(updateAliases[1])); +// expect(resultE.statusCode).toEqual(200); +// +// // Delete file and make sure aliases are deleted as well. +// await deleteFile({ id: fileWithAliases.id }); +// +// const resultF = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); +// +// expect(resultF.statusCode).toEqual(404); +// }); +// +// it("should trigger s3 file download - redirect", async () => { +// const handler = createDownloadHandler({ +// plugins: [createDownloadFileByExactKeyPlugins()] +// }); +// +// const result = await handler(createFileDownloadEvent(`/files/${Files.largeFile}`)); +// +// expect(result).toEqual({ +// body: "", +// headers: { +// "access-control-allow-headers": "*", +// "access-control-allow-methods": "GET,HEAD", +// "access-control-allow-origin": "*", +// "cache-control": "public, max-age=900", +// connection: "keep-alive", +// "content-length": "0", +// "content-type": "application/json; charset=utf-8", +// date: expect.any(String), +// location: "https://presigned-domain.loc/some-url?1fjdsfjdsfds" +// }, +// isBase64Encoded: false, +// statusCode: 301 +// }); +// }); +// }); diff --git a/packages/api-file-manager/__tests__/handlers/download.test.ts b/packages/api-file-manager/__tests__/handlers/download.test.ts deleted file mode 100644 index e605ade3edc..00000000000 --- a/packages/api-file-manager/__tests__/handlers/download.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; -import { createHandler } from "@webiny/handler-aws"; -import { - createDownloadFileByAliasPlugins, - createDownloadFileByExactKeyPlugins -} from "~/handlers/download"; -import useGqlHandler from "~tests/utils/useGqlHandler"; -import { fileAData } from "~tests/mocks/files"; -import { - APIGatewayEvent, - APIGatewayEventRequestContextWithAuthorizer -} from "@webiny/handler-aws/types"; - -const binaryMimeTypes: string[] = []; -binaryMimeTypes.indexOf = () => { - return 1; -}; - -enum Files { - smallFile = "small-test-file-path.png", - largeFile = "large-test-file-path.png" -} - -jest.mock("@webiny/aws-sdk/client-s3", () => { - return { - S3: class { - private _fileSizes = { - ["small-test-file-path.png"]: 137, - ["large-test-file-path.png"]: 500000001 - }; - - async headObject(obj: any) { - const { Key: file } = obj; - return { - ContentLength: this._fileSizes[file as Files] - }; - } - - async getObject(obj: any) { - const { Key: file } = obj; - return { - Body: file, - ContentType: "image/png", - ContentLength: this._fileSizes[file as Files] || file.length - }; - } - }, - - getSignedUrl() { - return "https://presigned-domain.loc/some-url?1fjdsfjdsfds"; - }, - GetObjectCommand: class {} - }; -}); - -jest.mock("~/handlers/transform/loaders", () => { - return []; -}); - -const createFileDownloadEvent = (path: string): APIGatewayEvent => { - return { - path, - body: "", - multiValueQueryStringParameters: null, - httpMethod: "GET", - headers: {}, - pathParameters: { - path: path.includes("/files/") ? path.replace("/files/", "") : path - }, - queryStringParameters: null, - isBase64Encoded: false, - requestContext: {} as APIGatewayEventRequestContextWithAuthorizer, - resource: "", - multiValueHeaders: {}, - stageVariables: null - }; -}; - -const createDownloadHandler = (params: Parameters[0]) => { - const handler = createHandler(params); - return (event: APIGatewayEvent, context: any = {}) => { - return handler(event, context); - }; -}; - -describe("download handler", () => { - beforeEach(() => { - process.env.S3_BUCKET = "some-bucket"; - process.env.AWS_REGION = "eu-central-1"; - }); - - it("should trigger s3 file download - stream", async () => { - const handler = createDownloadHandler({ - plugins: [createDownloadFileByExactKeyPlugins()] - }); - - const result = await handler(createFileDownloadEvent(`/files/${Files.smallFile}`)); - - expect(result).toEqual({ - body: "c21hbGwtdGVzdC1maWxlLXBhdGgucG5n", - isBase64Encoded: true, - statusCode: 200, - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "GET,HEAD", - "access-control-allow-origin": "*", - "cache-control": "public, max-age=30758400", - connection: "keep-alive", - "content-length": "24", - "content-type": "image/png", - date: expect.any(String), - "x-webiny-base64-encoded": "true" - } - }); - }); - - it("should trigger s3 file download via a file alias", async () => { - const documentClient = getDocumentClient(); - - // TODO: ideally, this should be configured as storage ops, to abstract tests away from implementation details, - // TODO: like DynamoDB document client, handler setups, etc. - const handler = createDownloadHandler({ - plugins: [createDownloadFileByAliasPlugins({ documentClient })] - }); - - const createAliases = ["/test-file.png", "/my/custom/path.jpeg"]; - const updateAliases = ["/test-file.png", "/test-file-2.png"]; - - const fileWithAliases = { - ...fileAData, - aliases: createAliases - }; - - const fileBody = Buffer.from(fileWithAliases.key).toString("base64"); - const contentLength = fileWithAliases.key.length.toString(); - - const { createFile, updateFile, deleteFile } = useGqlHandler(); - - // Create a file and make sure it's accessible via all provided aliases. - const [result] = await createFile({ data: fileWithAliases }); - - expect(result).toMatchObject({ - data: { - fileManager: { - createFile: { - data: { - aliases: ["/test-file.png", "/my/custom/path.jpeg"] - }, - error: null - } - } - } - }); - - const resultA = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); - - expect(resultA).toEqual({ - body: fileBody, - isBase64Encoded: true, - statusCode: 200, - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "GET,HEAD", - "access-control-allow-origin": "*", - "cache-control": "public, max-age=30758400", - connection: "keep-alive", - "content-length": contentLength, - "content-type": "image/png", - date: expect.any(String), - "x-webiny-base64-encoded": "true" - } - }); - - const resultB = await handler(createFileDownloadEvent(fileWithAliases.aliases[1])); - - expect(resultB).toEqual({ - body: fileBody, - isBase64Encoded: true, - statusCode: 200, - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "GET,HEAD", - "access-control-allow-origin": "*", - "cache-control": "public, max-age=30758400", - connection: "keep-alive", - "content-length": contentLength, - "content-type": "image/png", - date: expect.any(String), - "x-webiny-base64-encoded": "true" - } - }); - - // Update aliases, and make sure file is not accessible via old aliases. - await updateFile({ - id: fileWithAliases.id, - data: { - aliases: updateAliases - } - }); - - // First alias should still be accessible. - const resultC = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); - - expect(resultC.statusCode).toEqual(200); - - // Second original alias should not be accessible. - const resultD = await handler(createFileDownloadEvent(fileWithAliases.aliases[1])); - expect(resultD.statusCode).toEqual(404); - - // Second updated alias should be accessible. - const resultE = await handler(createFileDownloadEvent(updateAliases[1])); - expect(resultE.statusCode).toEqual(200); - - // Delete file and make sure aliases are deleted as well. - await deleteFile({ id: fileWithAliases.id }); - - const resultF = await handler(createFileDownloadEvent(fileWithAliases.aliases[0])); - - expect(resultF.statusCode).toEqual(404); - }); - - it("should trigger s3 file download - redirect", async () => { - const handler = createDownloadHandler({ - plugins: [createDownloadFileByExactKeyPlugins()] - }); - - const result = await handler(createFileDownloadEvent(`/files/${Files.largeFile}`)); - - expect(result).toEqual({ - body: "", - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "GET,HEAD", - "access-control-allow-origin": "*", - "cache-control": "public, max-age=900", - connection: "keep-alive", - "content-length": "0", - "content-type": "application/json; charset=utf-8", - date: expect.any(String), - location: "https://presigned-domain.loc/some-url?1fjdsfjdsfds" - }, - isBase64Encoded: false, - statusCode: 301 - }); - }); -}); diff --git a/packages/api-file-manager/__tests__/mocks/file.sdl.ts b/packages/api-file-manager/__tests__/mocks/file.sdl.ts index 0c8c7a804a1..bffe2dec35f 100644 --- a/packages/api-file-manager/__tests__/mocks/file.sdl.ts +++ b/packages/api-file-manager/__tests__/mocks/file.sdl.ts @@ -190,6 +190,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -197,21 +204,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -219,13 +245,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -233,6 +252,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -251,14 +277,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -267,61 +293,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] location: FmFile_LocationWhereInput name: String @@ -398,30 +369,26 @@ export default /* GraphQL */ ` enum FmFileListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC name_ASC name_DESC key_ASC diff --git a/packages/api-file-manager/package.json b/packages/api-file-manager/package.json index d7fd9bf9244..0d5d818f672 100644 --- a/packages/api-file-manager/package.json +++ b/packages/api-file-manager/package.json @@ -28,11 +28,11 @@ "@webiny/error": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", - "@webiny/handler-client": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/project-utils": "0.0.0", "@webiny/pubsub": "0.0.0", + "@webiny/tasks": "0.0.0", "@webiny/validation": "0.0.0", "lodash": "^4.17.11", "object-hash": "^2.1.1" diff --git a/packages/api-file-manager/src/FileManagerContextSetup.ts b/packages/api-file-manager/src/FileManagerContextSetup.ts index 45450b6f972..0ccdf12ed16 100644 --- a/packages/api-file-manager/src/FileManagerContextSetup.ts +++ b/packages/api-file-manager/src/FileManagerContextSetup.ts @@ -83,8 +83,10 @@ export class FileManagerContextSetup { return; } + const withPrivateFiles = this.context.wcp.canUsePrivateFiles(); + // This registers code plugins (model group, models) - const { groupPlugin, fileModelDefinition } = createFileManagerPlugins(); + const { groupPlugin, fileModelDefinition } = createFileManagerPlugins({ withPrivateFiles }); const modelModifiers = this.context.plugins.byType( CmsModelModifierPlugin.type diff --git a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts index e3fef9687a4..49725e3219c 100644 --- a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts +++ b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts @@ -72,16 +72,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { const entry = await this.security.withoutAuthorization(() => { return this.cms.createEntry(model, { ...file, - wbyAco_location: file.location, - - // We're mapping on/by meta fields onto entry-level meta - // fields because we don't use revisions with files. - entryCreatedOn: file.createdOn, - entryModifiedOn: file.modifiedOn, - entrySavedOn: file.savedOn, - entryCreatedBy: file.createdBy, - entryModifiedBy: file.modifiedBy, - entrySavedBy: file.savedBy + wbyAco_location: file.location }); }); @@ -180,16 +171,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { const updatedEntry = await this.cms.updateEntry(model, entry.id, { ...values, - wbyAco_location: values.location ?? entry.location, - - // We're mapping on/by meta fields onto entry-level meta - // fields because we don't use revisions with files. - entryCreatedOn: file.createdOn, - entryModifiedOn: file.modifiedOn, - entrySavedOn: file.savedOn, - entryCreatedBy: file.createdBy, - entryModifiedBy: file.modifiedBy, - entrySavedBy: file.savedBy + wbyAco_location: values.location ?? entry.location }); await this.aliases.storeAliases(file); @@ -203,12 +185,12 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { id: entry.entryId, // We're safe to use entry-level meta fields because we don't use revisions with files. - createdBy: entry.entryCreatedBy, - modifiedBy: entry.entryModifiedBy || null, - savedBy: entry.entrySavedBy, - createdOn: entry.entryCreatedOn, - modifiedOn: entry.entryModifiedOn || null, - savedOn: entry.entrySavedOn, + createdBy: entry.createdBy, + modifiedBy: entry.modifiedBy || null, + savedBy: entry.savedBy, + createdOn: entry.createdOn, + modifiedOn: entry.modifiedOn || null, + savedOn: entry.savedOn, locale: entry.locale, tenant: entry.tenant, diff --git a/packages/api-file-manager/src/cmsFileStorage/createFileManagerPlugins.ts b/packages/api-file-manager/src/cmsFileStorage/createFileManagerPlugins.ts index 3abf20932d2..47f14e0fdf3 100644 --- a/packages/api-file-manager/src/cmsFileStorage/createFileManagerPlugins.ts +++ b/packages/api-file-manager/src/cmsFileStorage/createFileManagerPlugins.ts @@ -1,8 +1,11 @@ import { CmsGroupPlugin } from "@webiny/api-headless-cms"; -// import { modelFactory } from "~/cmsFileStorage/modelFactory"; import { createFileModelDefinition } from "~/cmsFileStorage/file.model"; -export const createFileManagerPlugins = () => { +interface CreateFileManagerPluginsParams { + withPrivateFiles: boolean; +} + +export const createFileManagerPlugins = (params: CreateFileManagerPluginsParams) => { const groupId = "contentModelGroup_fm"; const groupPlugin = new CmsGroupPlugin({ @@ -14,15 +17,11 @@ export const createFileManagerPlugins = () => { isPrivate: true }); - // const models = modelDefinitions.map(modelDefinition => { - // return modelFactory({ - // group: cmsGroupPlugin.contentModelGroup, - // modelDefinition - // }); - // }); - return { groupPlugin, - fileModelDefinition: createFileModelDefinition(groupPlugin.contentModelGroup) + fileModelDefinition: createFileModelDefinition({ + contentModelGroup: groupPlugin.contentModelGroup, + withPrivateFiles: params.withPrivateFiles + }) }; }; diff --git a/packages/api-file-manager/src/cmsFileStorage/createModelField.ts b/packages/api-file-manager/src/cmsFileStorage/createModelField.ts index 3aa7e688da6..ba46ea3dcc2 100644 --- a/packages/api-file-manager/src/cmsFileStorage/createModelField.ts +++ b/packages/api-file-manager/src/cmsFileStorage/createModelField.ts @@ -11,6 +11,7 @@ export const createModelField = (params: CreateModelFieldParams): CmsModelField label, fieldId: initialFieldId, type, + tags, settings = {}, listValidation = [], validation = [], @@ -30,6 +31,7 @@ export const createModelField = (params: CreateModelFieldParams): CmsModelField label, type, settings, + tags, listValidation, validation, multipleValues, diff --git a/packages/api-file-manager/src/cmsFileStorage/file.model.ts b/packages/api-file-manager/src/cmsFileStorage/file.model.ts index 22c64833445..1517095d903 100644 --- a/packages/api-file-manager/src/cmsFileStorage/file.model.ts +++ b/packages/api-file-manager/src/cmsFileStorage/file.model.ts @@ -84,10 +84,43 @@ const metaField = () => { }); }; +const accessControlTypeField = () => { + return createModelField({ + label: "Type", + type: "text", + predefinedValues: { + enabled: true, + values: [ + { + label: "Public", + value: "public", + selected: true + }, + { + label: "Private", + value: "private-authenticated" + } + ] + } + }); +}; + +const accessControlField = () => { + return createModelField({ + label: "Access Control", + type: "object", + tags: ["$bulk-edit"], + settings: { + fields: [accessControlTypeField()] + } + }); +}; + const tagsField = () => { return createModelField({ label: "Tags", type: "text", + tags: ["$bulk-edit"], multipleValues: true, validation: [required()] }); @@ -102,56 +135,60 @@ const aliasesField = () => { }); }; +const locationField = () => { + return createModelField({ + type: "object", + label: "Location", + fieldId: "location", + settings: { + fields: [ + createModelField({ + type: "text", + fieldId: "folderId", + label: "Folder ID", + settings: { + path: "location.folderId" + } + }) + ] + } + }); +}; + export const FILE_MODEL_ID = "fmFile"; -export const createFileModelDefinition = (group: CmsModelGroup): CmsPrivateModelFull => { +interface CreateFileModelDefinitionParams { + contentModelGroup: CmsModelGroup; + withPrivateFiles: boolean; +} + +export const createFileModelDefinition = ( + params: CreateFileModelDefinitionParams +): CmsPrivateModelFull => { + const fields = [ + locationField(), + nameField(), + keyField(), + typeField(), + sizeField(), + metaField(), + tagsField(), + aliasesField() + ]; + + if (params.withPrivateFiles) { + fields.push(accessControlField()); + } + return { name: "FmFile", modelId: FILE_MODEL_ID, titleFieldId: "name", - layout: [ - ["location"], - ["name"], - ["key"], - ["type"], - ["size"], - ["meta"], - ["tags"], - ["aliases"] - ], - fields: [ - { - id: "location", - type: "object", - storageId: "object@location", - label: "Location", - fieldId: "location", - settings: { - fields: [ - { - id: "folderId", - type: "text", - fieldId: "folderId", - label: "Folder ID", - storageId: "text@folderId", - settings: { - path: "location.folderId" - } - } - ] - } - }, - nameField(), - keyField(), - typeField(), - sizeField(), - metaField(), - tagsField(), - aliasesField() - ], + layout: [], + fields, description: "File Manager - File content model", isPrivate: true, - group, + group: params.contentModelGroup, noValidate: true }; }; diff --git a/packages/api-file-manager/src/createFileManager/files.crud.ts b/packages/api-file-manager/src/createFileManager/files.crud.ts index c773fef4355..825204501d5 100644 --- a/packages/api-file-manager/src/createFileManager/files.crud.ts +++ b/packages/api-file-manager/src/createFileManager/files.crud.ts @@ -80,7 +80,6 @@ export const createFilesCrud = (config: FileManagerConfig): FilesCRUD => { createdBy: utilsGetIdentity(input.createdBy, currentIdentity), modifiedBy: utilsGetIdentity(input.modifiedBy, null), savedBy: utilsGetIdentity(input.savedBy, currentIdentity), - ownedBy: utilsGetIdentity(input.createdBy, currentIdentity), tenant: getTenantId(), locale: getLocaleCode(), diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/AliasAssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/AliasAssetRequestResolver.ts new file mode 100644 index 00000000000..163fa3df152 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/AliasAssetRequestResolver.ts @@ -0,0 +1,71 @@ +import { Request } from "@webiny/handler/types"; +import { DynamoDBClient, QueryCommand, unmarshall } from "@webiny/aws-sdk/client-dynamodb"; +import { AssetRequest, AssetRequestResolver } from "~/delivery"; + +export class AliasAssetRequestResolver implements AssetRequestResolver { + private documentClient: DynamoDBClient; + private resolver: AssetRequestResolver; + + constructor(documentClient: DynamoDBClient, resolver: AssetRequestResolver) { + this.documentClient = documentClient; + this.resolver = resolver; + } + + async resolve(request: Request): Promise { + const resolvedAsset = await this.resolver.resolve(request); + + if (resolvedAsset) { + return resolvedAsset; + } + + const params = (request.params as Record) ?? {}; + const query = (request.query as Record) ?? {}; + const path = decodeURI(params["*"]); + + const tenant = query.tenant || "root"; + const fileKey = await this.getFileByAlias(tenant, path); + + if (!fileKey) { + return undefined; + } + + const { original, ...options } = query; + + return new AssetRequest({ + key: fileKey, + context: { + url: request.url + }, + options: { + original: original !== undefined, + ...options + } + }); + } + + private async getFileByAlias(tenant: string, alias: string): Promise { + const { Items } = await this.documentClient.send( + new QueryCommand({ + TableName: String(process.env.DB_TABLE), + IndexName: "GSI1", + Limit: 1, + KeyConditionExpression: "GSI1_PK = :GSI1_PK AND GSI1_SK = :GSI1_SK", + ExpressionAttributeValues: { + ":GSI1_PK": { S: `T#${tenant}#FM#FILE_ALIASES` }, + ":GSI1_SK": { S: alias } + } + }) + ); + + if (!Array.isArray(Items)) { + return null; + } + const [item] = Items; + if (!item) { + return null; + } + const { data } = unmarshall(item); + + return data?.key ?? null; + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts new file mode 100644 index 00000000000..ed209ee8470 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts @@ -0,0 +1,79 @@ +import { AssetContentsReader, AssetOutputStrategy } from "~/delivery"; + +type Setter = (arg: T | undefined) => T; + +export interface AssetData { + id: string; + tenant: string; + locale: string; + key: string; + size: number; + contentType: string; +} + +export class Asset { + protected readonly props: AssetData; + private outputStrategy: AssetOutputStrategy | undefined; + private contentsReader: AssetContentsReader | undefined; + + constructor(props: AssetData) { + this.props = props; + } + + clone() { + const clonedAsset = new Asset(structuredClone(this.props)); + clonedAsset.outputStrategy = this.outputStrategy; + clonedAsset.contentsReader = this.contentsReader; + return clonedAsset; + } + + getId() { + return this.props.id; + } + getTenant() { + return this.props.tenant; + } + getLocale() { + return this.props.locale; + } + getKey() { + return this.props.key; + } + async getSize() { + const buffer = await this.getContents(); + return buffer.length; + } + getContentType() { + return this.props.contentType; + } + getExtension() { + return this.getKey().split(".").pop() ?? ""; + } + + getContents() { + if (!this.contentsReader) { + throw Error(`Asset contents reader was not configured!`); + } + return this.contentsReader.read(this); + } + + setContentsReader(reader: AssetContentsReader) { + this.contentsReader = reader; + } + + output() { + if (!this.outputStrategy) { + throw Error(`Asset output strategy was not configured!`); + } + + return this.outputStrategy.output(this); + } + + setOutputStrategy(setter: Setter | AssetOutputStrategy) { + if (typeof setter === "function") { + this.outputStrategy = setter(this.outputStrategy); + } else { + this.outputStrategy = setter; + } + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts b/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts new file mode 100644 index 00000000000..36622ee125d --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts @@ -0,0 +1,151 @@ +import { Plugin } from "@webiny/plugins"; +import { + AssetRequestResolver, + AssetResolver, + AssetProcessor, + AssetOutputStrategy, + AssetRequest, + Asset, + AssetTransformationStrategy, + ResponseHeadersSetter, + SetResponseHeaders +} from "~/delivery"; +import { FileManagerContext } from "~/types"; +import { NullRequestResolver } from "~/delivery/AssetDelivery/NullRequestResolver"; +import { NullAssetResolver } from "~/delivery/AssetDelivery/NullAssetResolver"; +import { NullAssetOutputStrategy } from "./NullAssetOutputStrategy"; +import { TransformationAssetProcessor } from "./transformation/TransformationAssetProcessor"; +import { PassthroughAssetTransformationStrategy } from "./transformation/PassthroughAssetTransformationStrategy"; + +type Setter = (params: TParams) => TReturn; + +export type AssetRequestResolverDecorator = Setter< + { assetRequestResolver: AssetRequestResolver }, + AssetRequestResolver +>; + +export type AssetResolverDecorator = Setter<{ assetResolver: AssetResolver }, AssetResolver>; + +export type AssetProcessorDecorator = Setter< + { context: FileManagerContext; assetProcessor: AssetProcessor }, + AssetProcessor +>; + +export type AssetTransformationDecorator = Setter< + { context: FileManagerContext; assetTransformationStrategy: AssetTransformationStrategy }, + AssetTransformationStrategy +>; + +export interface AssetOutputStrategyDecoratorParams { + context: FileManagerContext; + assetRequest: AssetRequest; + asset: Asset; + assetOutputStrategy: AssetOutputStrategy; +} + +export type AssetOutputStrategyDecorator = Setter< + AssetOutputStrategyDecoratorParams, + AssetOutputStrategy +>; + +export class AssetDeliveryConfigBuilder { + private assetRequestResolverDecorators: AssetRequestResolverDecorator[] = []; + private assetResolverDecorators: AssetResolverDecorator[] = []; + private assetProcessorDecorators: AssetProcessorDecorator[] = []; + private assetTransformationStrategyDecorators: AssetTransformationDecorator[] = []; + private assetOutputStrategyDecorators: AssetOutputStrategyDecorator[] = []; + + setResponseHeaders(setter: ResponseHeadersSetter) { + this.decorateAssetOutputStrategy(params => { + return new SetResponseHeaders(setter, params); + }); + } + + decorateAssetRequestResolver(decorator: AssetRequestResolverDecorator) { + this.assetRequestResolverDecorators.push(decorator); + } + + decorateAssetResolver(decorator: AssetResolverDecorator) { + this.assetResolverDecorators.push(decorator); + } + + decorateAssetProcessor(decorator: AssetProcessorDecorator) { + this.assetProcessorDecorators.push(decorator); + } + + decorateAssetTransformationStrategy(decorator: AssetTransformationDecorator) { + this.assetTransformationStrategyDecorators.push(decorator); + } + + decorateAssetOutputStrategy(decorator: AssetOutputStrategyDecorator) { + this.assetOutputStrategyDecorators.push(decorator); + } + + /** + * @internal + */ + getAssetRequestResolver() { + return this.assetRequestResolverDecorators.reduce( + (value, decorator) => decorator({ assetRequestResolver: value }), + new NullRequestResolver() + ); + } + + /** + * @internal + */ + getAssetResolver() { + return this.assetResolverDecorators.reduce( + (value, decorator) => decorator({ assetResolver: value }), + new NullAssetResolver() + ); + } + + /** + * @internal + */ + getAssetProcessor(context: FileManagerContext) { + return this.assetProcessorDecorators.reduce( + (value, decorator) => decorator({ assetProcessor: value, context }), + new TransformationAssetProcessor(this.getAssetTransformationStrategy(context)) + ); + } + + getAssetOutputStrategy(context: FileManagerContext, assetRequest: AssetRequest, asset: Asset) { + return this.assetOutputStrategyDecorators.reduce( + (value, decorator) => { + return decorator({ context, assetRequest, asset, assetOutputStrategy: value }); + }, + new NullAssetOutputStrategy() + ); + } + + getAssetTransformationStrategy(context: FileManagerContext) { + return this.assetTransformationStrategyDecorators.reduce( + (value, decorator) => decorator({ context, assetTransformationStrategy: value }), + new PassthroughAssetTransformationStrategy() + ); + } +} + +export interface AssetDeliveryConfigModifier { + (config: AssetDeliveryConfigBuilder): Promise | void; +} + +export class AssetDeliveryConfigModifierPlugin extends Plugin { + public static override type = "fm.config-modifier"; + private readonly cb: AssetDeliveryConfigModifier; + + constructor(cb: AssetDeliveryConfigModifier) { + super(); + this.cb = cb; + } + + async buildConfig(configBuilder: AssetDeliveryConfigBuilder): Promise { + await this.cb(configBuilder); + } +} + +export const createAssetDeliveryConfig = (cb: AssetDeliveryConfigModifier) => { + return new AssetDeliveryConfigModifierPlugin(cb); +}; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/AssetRequest.ts b/packages/api-file-manager/src/delivery/AssetDelivery/AssetRequest.ts new file mode 100644 index 00000000000..3b849402fd2 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/AssetRequest.ts @@ -0,0 +1,45 @@ +export interface AssetRequestOptions { + original?: boolean; + width?: number; +} + +export type AssetRequestContext = Record> = T & { + /** + * Asset request URL. + */ + url: string; +}; + +export interface AssetRequestData { + key: string; + context: AssetRequestContext; + options: TOptions; +} + +export class AssetRequest { + private data: AssetRequestData; + + constructor(data: AssetRequestData) { + this.data = data; + } + + getKey() { + return this.data.key; + } + + getOptions(): TOptions { + return this.data.options; + } + + setOptions(options: TOptions) { + this.data.options = options; + } + + getContext() { + return this.data.context as AssetRequestContext; + } + + getExtension() { + return this.data.key.split(".").pop(); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts new file mode 100644 index 00000000000..b0b17f62685 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/FilesAssetRequestResolver.ts @@ -0,0 +1,29 @@ +import { Request } from "@webiny/handler/types"; +import { AssetRequestResolver } from "./abstractions/AssetRequestResolver"; +import { AssetRequest } from "./AssetRequest"; + +export class FilesAssetRequestResolver implements AssetRequestResolver { + async resolve(request: Request): Promise { + // Example: /files/65722cb5c7824a0008d05963/image-48.jpg?width=300 + if (!request.url.startsWith("/files/")) { + return undefined; + } + + const params = (request.params as Record) ?? {}; + const query = (request.query as Record) ?? {}; + + // Example: { '*': '/files/65722cb5c7824a0008d05963/image-48.jpg' }, + const path = params["*"]; + + return new AssetRequest({ + key: decodeURI(path).replace("/files/", ""), + context: { + url: request.url + }, + options: { + ...query, + width: query.width ? parseInt(query.width) : undefined + } + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetOutputStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetOutputStrategy.ts new file mode 100644 index 00000000000..ad3290fef99 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetOutputStrategy.ts @@ -0,0 +1,8 @@ +import { AssetOutputStrategy, AssetReply } from "~/delivery"; +import { NullAssetReply } from "./NullAssetReply"; + +export class NullAssetOutputStrategy implements AssetOutputStrategy { + async output(): Promise { + return new NullAssetReply(); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetReply.ts b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetReply.ts new file mode 100644 index 00000000000..96d44c0c7a0 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetReply.ts @@ -0,0 +1,10 @@ +import { AssetReply } from "~/delivery/AssetDelivery/abstractions/AssetReply"; + +export class NullAssetReply extends AssetReply { + constructor() { + super({ + code: 404, + body: () => ({ error: "Asset output strategy is not implemented!" }) + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetResolver.ts new file mode 100644 index 00000000000..4cf7bc1daed --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/NullAssetResolver.ts @@ -0,0 +1,8 @@ +import { AssetResolver } from "./abstractions/AssetResolver"; +import { Asset } from "./Asset"; + +export class NullAssetResolver implements AssetResolver { + resolve(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/NullRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/NullRequestResolver.ts new file mode 100644 index 00000000000..6f4b60424e1 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/NullRequestResolver.ts @@ -0,0 +1,7 @@ +import { AssetRequest, AssetRequestResolver } from "~/delivery"; + +export class NullRequestResolver implements AssetRequestResolver { + resolve(): Promise { + return Promise.resolve(undefined); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/SetCacheControlHeaders.ts b/packages/api-file-manager/src/delivery/AssetDelivery/SetCacheControlHeaders.ts new file mode 100644 index 00000000000..b47fa2c12a7 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/SetCacheControlHeaders.ts @@ -0,0 +1,26 @@ +import { ResponseHeaders } from "@webiny/handler"; +import { Asset, AssetOutputStrategy, AssetReply } from "~/delivery"; + +export class SetCacheControlHeaders implements AssetOutputStrategy { + private readonly strategy: AssetOutputStrategy | undefined; + private readonly headers: ResponseHeaders; + + constructor(headers: ResponseHeaders, strategy: AssetOutputStrategy | undefined) { + this.headers = headers; + this.strategy = strategy; + } + + async output(asset: Asset): Promise { + if (!this.strategy) { + throw Error(`No asset output strategy is configured!`); + } + + const reply = await this.strategy.output(asset); + + reply.setHeaders(headers => { + return headers.merge(this.headers); + }); + + return reply; + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts b/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts new file mode 100644 index 00000000000..d754a73044f --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts @@ -0,0 +1,46 @@ +import { + AssetOutputStrategy, + Asset, + AssetReply, + AssetRequest, + AssetOutputStrategyDecoratorParams +} from "~/delivery"; +import { FileManagerContext } from "~/types"; +import { ResponseHeaders } from "@webiny/handler"; + +export interface ResponseHeadersParams { + headers: ResponseHeaders; + context: FileManagerContext; + assetRequest: AssetRequest; + asset: Asset; +} + +export interface ResponseHeadersSetter { + (params: ResponseHeadersParams): Promise | void; +} + +export class SetResponseHeaders implements AssetOutputStrategy { + private readonly setter: ResponseHeadersSetter; + private strategyDecoratorParams: AssetOutputStrategyDecoratorParams; + + constructor( + setter: ResponseHeadersSetter, + strategyDecoratorParams: AssetOutputStrategyDecoratorParams + ) { + this.strategyDecoratorParams = strategyDecoratorParams; + this.setter = setter; + } + + async output(asset: Asset): Promise { + const reply = await this.strategyDecoratorParams.assetOutputStrategy.output(asset); + + await this.setter({ + asset: this.strategyDecoratorParams.asset, + assetRequest: this.strategyDecoratorParams.assetRequest, + context: this.strategyDecoratorParams.context, + headers: reply.getHeaders() + }); + + return reply; + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetContentsReader.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetContentsReader.ts new file mode 100644 index 00000000000..d6bd4fbe07b --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetContentsReader.ts @@ -0,0 +1,5 @@ +import { Asset } from "~/delivery"; + +export interface AssetContentsReader { + read(asset: Asset): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetOutputStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetOutputStrategy.ts new file mode 100644 index 00000000000..a5b1e4141f4 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetOutputStrategy.ts @@ -0,0 +1,5 @@ +import { Asset, AssetReply } from "~/delivery"; + +export interface AssetOutputStrategy { + output(asset: Asset): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetProcessor.ts new file mode 100644 index 00000000000..2816bb6cc30 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetProcessor.ts @@ -0,0 +1,5 @@ +import { Asset, AssetRequest } from "~/delivery"; + +export interface AssetProcessor { + process(assetRequest: AssetRequest, asset: Asset): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetReply.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetReply.ts new file mode 100644 index 00000000000..20a125562f9 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetReply.ts @@ -0,0 +1,53 @@ +import { ResponseHeaders } from "@webiny/handler"; + +interface HeadersSetter { + (headers: ResponseHeaders): ResponseHeaders; +} + +interface AssetReplyParams { + code: number; + headers?: ResponseHeaders; + body?: AssetReplyBody; +} + +interface AssetReplyBody { + (): Promise | unknown; +} + +const defaultBody = () => ""; + +export class AssetReply { + private headers: ResponseHeaders; + private code: number; + private body: AssetReplyBody; + + constructor(params: AssetReplyParams = { code: 200 }) { + this.code = params.code; + this.headers = params.headers || ResponseHeaders.create(); + this.body = params.body || defaultBody; + } + + setHeaders(cb: HeadersSetter) { + this.headers = cb(this.headers); + } + + getHeaders() { + return this.headers; + } + + setCode(code: number) { + this.code = code; + } + + getCode() { + return this.code; + } + + setBody(body: AssetReplyBody) { + this.body = body; + } + + getBody() { + return this.body(); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetRequestResolver.ts new file mode 100644 index 00000000000..220a27f30f4 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetRequestResolver.ts @@ -0,0 +1,6 @@ +import { Request } from "@webiny/handler/types"; +import { AssetRequest } from "~/delivery"; + +export interface AssetRequestResolver { + resolve(request: Request): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetResolver.ts new file mode 100644 index 00000000000..5cd2196d163 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetResolver.ts @@ -0,0 +1,5 @@ +import { Asset, AssetRequest } from "~/delivery"; + +export interface AssetResolver { + resolve(request: AssetRequest): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetTransformationStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetTransformationStrategy.ts new file mode 100644 index 00000000000..c7451cf9121 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/abstractions/AssetTransformationStrategy.ts @@ -0,0 +1,5 @@ +import { Asset, AssetRequest } from "~/delivery"; + +export interface AssetTransformationStrategy { + transform(assetRequest: AssetRequest, asset: Asset): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/createAssetDeliveryPluginLoader.ts b/packages/api-file-manager/src/delivery/AssetDelivery/createAssetDeliveryPluginLoader.ts new file mode 100644 index 00000000000..831a376edf4 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/createAssetDeliveryPluginLoader.ts @@ -0,0 +1,9 @@ +import { PluginFactory } from "@webiny/plugins/types"; + +export const createAssetDeliveryPluginLoader = (cb: PluginFactory): PluginFactory => { + if (process.env.WEBINY_FUNCTION_TYPE === "asset-delivery") { + return () => cb(); + } + + return () => Promise.resolve([]); +}; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts new file mode 100644 index 00000000000..0b3075d34fc --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts @@ -0,0 +1,5 @@ +import { File } from "~/types"; + +export interface AssetAuthorizer { + authorize(file: File): Promise; +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedAssetReply.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedAssetReply.ts new file mode 100644 index 00000000000..b669edae558 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedAssetReply.ts @@ -0,0 +1,15 @@ +import { ResponseHeaders } from "@webiny/handler"; +import { AssetReply } from "~/delivery"; + +export class NotAuthorizedAssetReply extends AssetReply { + constructor() { + super({ + code: 403, + headers: ResponseHeaders.create({ + "cache-control": "no-store", + "content-type": "application/json" + }), + body: () => ({ error: "Not authorized!", code: "NOT_AUTHORIZED" }) + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedOutputStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedOutputStrategy.ts new file mode 100644 index 00000000000..242b13392ff --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/NotAuthorizedOutputStrategy.ts @@ -0,0 +1,8 @@ +import { AssetOutputStrategy, AssetReply } from "~/delivery"; +import { NotAuthorizedAssetReply } from "./NotAuthorizedAssetReply"; + +export class NotAuthorizedOutputStrategy implements AssetOutputStrategy { + async output(): Promise { + return new NotAuthorizedAssetReply(); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts new file mode 100644 index 00000000000..9844065f202 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts @@ -0,0 +1,25 @@ +import Error from "@webiny/error"; +import { File, FileManagerContext } from "~/types"; +import { AssetAuthorizer } from "./AssetAuthorizer"; + +export class PrivateAuthenticatedAuthorizer implements AssetAuthorizer { + private context: FileManagerContext; + + constructor(context: FileManagerContext) { + this.context = context; + } + + async authorize(file: File) { + if (file.accessControl && file.accessControl.type === "private-authenticated") { + // Make sure there's a valid identity! + const identity = this.context.security.getIdentity(); + + if (!identity) { + throw new Error({ + code: "NOT_AUTHORIZED", + message: "You're not authorized to access this asset!" + }); + } + } + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateCache.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateCache.ts new file mode 100644 index 00000000000..7ef97438965 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateCache.ts @@ -0,0 +1,13 @@ +import { AssetOutputStrategy, SetCacheControlHeaders } from "~/delivery"; +import { ResponseHeaders } from "@webiny/handler"; + +export class PrivateCache extends SetCacheControlHeaders { + constructor(days: number, strategy: AssetOutputStrategy | undefined) { + super( + ResponseHeaders.create({ + "cache-control": `private, max-age=${86400 * days}` + }), + strategy + ); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFileAssetRequestResolver.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFileAssetRequestResolver.ts new file mode 100644 index 00000000000..dda3e6b7ac3 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFileAssetRequestResolver.ts @@ -0,0 +1,35 @@ +import { AssetRequest, AssetRequestResolver } from "~/delivery"; +import { Request } from "@webiny/handler/types"; + +export class PrivateFileAssetRequestResolver implements AssetRequestResolver { + private readonly resolver: AssetRequestResolver; + + constructor(resolver: AssetRequestResolver) { + this.resolver = resolver; + } + + async resolve(request: Request): Promise { + // Example: /private/65722cb5c7824a0008d05963/image-48.jpg?width=300 + if (!request.url.startsWith("/private/")) { + return this.resolver.resolve(request); + } + + const params = (request.params ?? {}) as Record; + const query = (request.query ?? {}) as Record; + + // Example: { '*': '/private/65722cb5c7824a0008d05963/image-48.jpg' }, + const path = params["*"]; + + return new AssetRequest({ + key: decodeURI(path).replace("/private/", ""), + context: { + url: request.url, + private: true + }, + options: { + ...query, + width: query.width ? parseInt(query.width) : undefined + } + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts new file mode 100644 index 00000000000..f038ed10e37 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts @@ -0,0 +1,79 @@ +import { File, FileManagerContext } from "~/types"; +import { Asset, AssetProcessor, AssetRequest } from "~/delivery"; +import { AssetAuthorizer } from "./AssetAuthorizer"; +import { NotAuthorizedOutputStrategy } from "./NotAuthorizedOutputStrategy"; +import { internalIdentity } from "./internalIdentity"; +import { RedirectToPublicUrlOutputStrategy } from "./RedirectToPublicUrlOutputStrategy"; +import { RedirectToPrivateUrlOutputStrategy } from "./RedirectToPrivateUrlOutputStrategy"; +import { PrivateCache } from "./PrivateCache"; +import { PublicCache } from "./PublicCache"; + +interface MaybePrivate { + private?: boolean; +} + +export class PrivateFilesAssetProcessor implements AssetProcessor { + private readonly context: FileManagerContext; + private assetProcessor: AssetProcessor; + private assetAuthorizer: AssetAuthorizer; + + constructor( + context: FileManagerContext, + assetAuthorizer: AssetAuthorizer, + assetProcessor: AssetProcessor + ) { + this.assetAuthorizer = assetAuthorizer; + this.context = context; + this.assetProcessor = assetProcessor; + } + + async process(assetRequest: AssetRequest, asset: Asset): Promise { + const id = asset.getId(); + const { security } = this.context; + + // Get file from File Manager by `id`. + const file = await security.withIdentity(internalIdentity, () => { + return security.withoutAuthorization(() => this.context.fileManager.getFile(id)); + }); + + const isPrivateFile = this.isPrivate(file); + + if (!isPrivateFile && this.requestedViaPrivateEndpoint(assetRequest)) { + asset.setOutputStrategy(new RedirectToPublicUrlOutputStrategy(assetRequest)); + return asset; + } + + if (isPrivateFile && this.requestedViaPublicEndpoint(assetRequest)) { + asset.setOutputStrategy(new RedirectToPrivateUrlOutputStrategy(assetRequest)); + return asset; + } + + try { + await this.assetAuthorizer.authorize(file); + } catch (error) { + asset.setOutputStrategy(new NotAuthorizedOutputStrategy()); + + return asset; + } + + const processedAsset = await this.assetProcessor.process(assetRequest, asset); + + processedAsset.setOutputStrategy(strategy => { + return isPrivateFile ? new PrivateCache(30, strategy) : new PublicCache(365, strategy); + }); + + return processedAsset; + } + + private isPrivate(file: File) { + return file.accessControl && file.accessControl.type.startsWith("private-"); + } + + private requestedViaPrivateEndpoint(assetRequest: AssetRequest) { + return assetRequest.getContext().private; + } + + private requestedViaPublicEndpoint(assetRequest: AssetRequest) { + return !this.requestedViaPrivateEndpoint(assetRequest); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PublicCache.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PublicCache.ts new file mode 100644 index 00000000000..4295e7580a9 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PublicCache.ts @@ -0,0 +1,13 @@ +import { AssetOutputStrategy, SetCacheControlHeaders } from "~/delivery"; +import { ResponseHeaders } from "@webiny/handler"; + +export class PublicCache extends SetCacheControlHeaders { + constructor(days: number, strategy: AssetOutputStrategy | undefined) { + super( + ResponseHeaders.create({ + "cache-control": `public, max-age=${86400 * days}` + }), + strategy + ); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPrivateUrlOutputStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPrivateUrlOutputStrategy.ts new file mode 100644 index 00000000000..57b7a00f8b9 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPrivateUrlOutputStrategy.ts @@ -0,0 +1,23 @@ +import { Asset, AssetOutputStrategy, AssetReply, AssetRequest } from "~/delivery"; +import { ResponseHeaders } from "@webiny/handler"; + +export class RedirectToPrivateUrlOutputStrategy implements AssetOutputStrategy { + private assetRequest: AssetRequest; + + constructor(assetRequest: AssetRequest) { + this.assetRequest = assetRequest; + } + + async output(asset: Asset): Promise { + const requestUrl = this.assetRequest.getContext().url; + + return new AssetReply({ + code: 301, + headers: ResponseHeaders.create({ + location: requestUrl.replace("/files/", "/private/"), + "content-type": asset.getContentType(), + "cache-control": `public, max-age=${86400 * 30}` + }) + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPublicUrlOutputStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPublicUrlOutputStrategy.ts new file mode 100644 index 00000000000..581b4e0ff13 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/RedirectToPublicUrlOutputStrategy.ts @@ -0,0 +1,23 @@ +import { Asset, AssetOutputStrategy, AssetReply, AssetRequest } from "~/delivery"; +import { ResponseHeaders } from "@webiny/handler"; + +export class RedirectToPublicUrlOutputStrategy implements AssetOutputStrategy { + private assetRequest: AssetRequest; + + constructor(assetRequest: AssetRequest) { + this.assetRequest = assetRequest; + } + + async output(asset: Asset): Promise { + const requestUrl = this.assetRequest.getContext().url; + + return new AssetReply({ + code: 301, + headers: ResponseHeaders.create({ + location: requestUrl.replace("/private/", "/files/"), + "content-type": asset.getContentType(), + "cache-control": `public, max-age=${86400 * 30}` + }) + }); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/internalIdentity.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/internalIdentity.ts new file mode 100644 index 00000000000..ad8b34b98b9 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/internalIdentity.ts @@ -0,0 +1,12 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; + +/** + * This identity is used to bypass the current behavior of FLP. Even when using `withoutAuthorization`, + * FLP will complain, if the identity is `undefined`. Using this mock identity and `security.withIdentity`, + * we set this temporary identity for the duration of the callback execution, and FLP is happy. + */ +export const internalIdentity: SecurityIdentity = { + id: "asset-delivery", + type: "asset-delivery", + displayName: "" +}; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetProcessor.ts new file mode 100644 index 00000000000..64751b470ef --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetProcessor.ts @@ -0,0 +1,7 @@ +import { Asset, AssetProcessor, AssetRequest } from "~/delivery"; + +export class PassthroughAssetProcessor implements AssetProcessor { + process(assetRequest: AssetRequest, asset: Asset): Promise { + return Promise.resolve(asset); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetTransformationStrategy.ts b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetTransformationStrategy.ts new file mode 100644 index 00000000000..ad9fa25a250 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/PassthroughAssetTransformationStrategy.ts @@ -0,0 +1,7 @@ +import { Asset, AssetTransformationStrategy, AssetRequest } from "~/delivery"; + +export class PassthroughAssetTransformationStrategy implements AssetTransformationStrategy { + transform(assetRequest: AssetRequest, asset: Asset): Promise { + return Promise.resolve(asset); + } +} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts new file mode 100644 index 00000000000..fbd330330a7 --- /dev/null +++ b/packages/api-file-manager/src/delivery/AssetDelivery/transformation/TransformationAssetProcessor.ts @@ -0,0 +1,20 @@ +import { Asset, AssetProcessor, AssetRequest, AssetTransformationStrategy } from "~/delivery"; + +export class TransformationAssetProcessor implements AssetProcessor { + private strategy: AssetTransformationStrategy; + + constructor(strategy: AssetTransformationStrategy) { + this.strategy = strategy; + } + + async process(assetRequest: AssetRequest, asset: Asset): Promise { + const { original } = assetRequest.getOptions(); + + // If the `original` image was requested, we skip all transformations. + if (original) { + return asset; + } + + return this.strategy.transform(assetRequest, asset); + } +} diff --git a/packages/api-file-manager/src/delivery/index.ts b/packages/api-file-manager/src/delivery/index.ts new file mode 100644 index 00000000000..75f288829c7 --- /dev/null +++ b/packages/api-file-manager/src/delivery/index.ts @@ -0,0 +1,15 @@ +export * from "./AssetDelivery/AssetDeliveryConfig"; +export * from "./AssetDelivery/Asset"; +export * from "./AssetDelivery/AssetRequest"; +export * from "./AssetDelivery/abstractions/AssetRequestResolver"; +export * from "./AssetDelivery/abstractions/AssetResolver"; +export * from "./AssetDelivery/abstractions/AssetProcessor"; +export * from "./AssetDelivery/abstractions/AssetContentsReader"; +export * from "./AssetDelivery/abstractions/AssetOutputStrategy"; +export * from "./AssetDelivery/abstractions/AssetTransformationStrategy"; +export * from "./AssetDelivery/abstractions/AssetReply"; +export * from "./AssetDelivery/createAssetDeliveryPluginLoader"; +export * from "./AssetDelivery/FilesAssetRequestResolver"; +export * from "./AssetDelivery/AliasAssetRequestResolver"; +export * from "./AssetDelivery/SetCacheControlHeaders"; +export * from "./AssetDelivery/SetResponseHeaders"; diff --git a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts new file mode 100644 index 00000000000..9c111a82dd0 --- /dev/null +++ b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts @@ -0,0 +1,170 @@ +import { DynamoDBClient } from "@webiny/aws-sdk/client-dynamodb"; +import { + createHandlerOnRequest, + createModifyFastifyPlugin, + createRoute, + ResponseHeaders +} from "@webiny/handler"; +import { FileManagerContext } from "~/types"; +import { PrivateFilesAssetProcessor } from "./AssetDelivery/privateFiles/PrivateFilesAssetProcessor"; +import { PrivateAuthenticatedAuthorizer } from "./AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer"; +import { PrivateFileAssetRequestResolver } from "./AssetDelivery/privateFiles/PrivateFileAssetRequestResolver"; +import { + Asset, + AssetDeliveryConfigBuilder, + AssetDeliveryConfigModifierPlugin, + AssetRequest, + AliasAssetRequestResolver, + FilesAssetRequestResolver, + createAssetDeliveryConfig +} from "./index"; + +const noCacheHeaders = ResponseHeaders.create({ + "content-type": "application/json", + "cache-control": "no-cache, no-store, must-revalidate" +}); + +function assertAssetRequestWasResolved(request: any): asserts request is AssetRequest { + if (request === undefined) { + throw new Error("Not an AssetRequest!"); + } +} + +function assertAssetWasResolved(asset: Asset | undefined): asserts asset is Asset { + if (asset === undefined) { + throw new Error("Not an Asset!"); + } +} + +export interface AssetDeliveryParams { + documentClient: DynamoDBClient; +} + +export const setupAssetDelivery = (params: AssetDeliveryParams) => { + return [ + createModifyFastifyPlugin(app => { + console.log(app.webiny.args); + // Config builder allows config modification via plugins. + const configBuilder = new AssetDeliveryConfigBuilder(); + + // Apply config modifications. + const configPlugins = app.webiny.plugins.byType( + AssetDeliveryConfigModifierPlugin.type + ); + + configPlugins.forEach(configPlugin => configPlugin.buildConfig(configBuilder)); + + let resolvedRequest: AssetRequest | undefined; + let resolvedAsset: Asset | undefined; + + // Create a `HandlerOnRequest` plugin to resolve `tenant` and `locale`, and allow the system to bootstrap. + const handlerOnRequest = createHandlerOnRequest(async (request, reply) => { + const requestResolver = configBuilder.getAssetRequestResolver(); + resolvedRequest = await requestResolver.resolve(request); + + if (!resolvedRequest) { + reply + .code(404) + .headers(noCacheHeaders.getHeaders()) + .send({ error: "Unable to resolve the request!" }) + .hijack(); + + return false; + } + + const assetResolver = configBuilder.getAssetResolver(); + + resolvedAsset = await assetResolver.resolve(resolvedRequest); + + if (!resolvedAsset) { + reply + .code(404) + .headers(noCacheHeaders.getHeaders()) + .send({ error: "Asset not found!" }) + .hijack(); + + return false; + } + + request.headers = { + ...request.headers, + "x-tenant": resolvedAsset.getTenant(), + "x-i18n-locale": resolvedAsset.getLocale() + }; + + return; + }); + + // Create the `Route` plugin, to handle all GET requests, and output the resolved asset. + const deliveryRoute = createRoute(({ onGet, context }) => { + onGet( + "*", + async (_, reply) => { + assertAssetRequestWasResolved(resolvedRequest); + assertAssetWasResolved(resolvedAsset); + + if (context.wcp.canUsePrivateFiles()) { + configBuilder.decorateAssetProcessor(({ assetProcessor, context }) => { + // Currently, we only have one authorizer. + const assetAuthorizer = new PrivateAuthenticatedAuthorizer(context); + + return new PrivateFilesAssetProcessor( + context, + assetAuthorizer, + assetProcessor + ); + }); + } + + const outputStrategy = configBuilder.getAssetOutputStrategy( + context, + resolvedRequest, + resolvedAsset + ); + + resolvedAsset.setOutputStrategy(() => outputStrategy); + + const assetProcessor = configBuilder.getAssetProcessor(context); + + const processedAsset = await assetProcessor.process( + resolvedRequest, + resolvedAsset + ); + + // Get reply object (runs the output strategy under the hood). + const assetReply = await processedAsset.output(); + + const headers = assetReply.getHeaders(); + + // Set default headers. + headers.set("x-webiny-base64-encoded", true); + + reply.code(assetReply.getCode()); + reply.headers(headers.getHeaders()); + return reply.send(await assetReply.getBody()); + }, + { override: true } + ); + }); + + app.webiny.plugins.register(handlerOnRequest, deliveryRoute); + }), + // Create the default configuration + createAssetDeliveryConfig(config => { + config.decorateAssetRequestResolver(() => { + // This resolver works with `/files/*` requests. + return new FilesAssetRequestResolver(); + }); + + config.decorateAssetRequestResolver(({ assetRequestResolver }) => { + // This resolver tries to resolve the request using aliases. + return new AliasAssetRequestResolver(params.documentClient, assetRequestResolver); + }); + + config.decorateAssetRequestResolver(({ assetRequestResolver }) => { + // This resolver works with `/private/*` requests. + return new PrivateFileAssetRequestResolver(assetRequestResolver); + }); + }) + ]; +}; diff --git a/packages/api-file-manager/src/handlers/download/byAlias.ts b/packages/api-file-manager/src/handlers/download/byAlias.ts deleted file mode 100644 index edad53db483..00000000000 --- a/packages/api-file-manager/src/handlers/download/byAlias.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { GetObjectCommand, getSignedUrl, S3 } from "@webiny/aws-sdk/client-s3"; -import { DynamoDBClient, QueryCommand, unmarshall } from "@webiny/aws-sdk/client-dynamodb"; -import { getEnvironment } from "../utils"; -import { RoutePlugin } from "@webiny/handler-aws"; -import { extractFileInformation } from "./extractFileInformation"; -import { getS3Object, isSmallObject } from "./getS3Object"; - -const DEFAULT_CACHE_MAX_AGE = 30758400; // 1 year -const PRESIGNED_URL_EXPIRATION = 900; // 15 minutes - -const { region } = getEnvironment(); -const s3 = new S3({ region }); - -export interface DownloadByFileAliasConfig { - documentClient: DynamoDBClient; -} - -export const createDownloadFileByAliasPlugins = ({ documentClient }: DownloadByFileAliasConfig) => { - async function getFileByAlias(tenant: string, alias: string): Promise { - const { Items } = await documentClient.send( - new QueryCommand({ - TableName: String(process.env.DB_TABLE), - IndexName: "GSI1", - Limit: 1, - KeyConditionExpression: "GSI1_PK = :GSI1_PK AND GSI1_SK = :GSI1_SK", - ExpressionAttributeValues: { - ":GSI1_PK": { S: `T#${tenant}#FM#FILE_ALIASES` }, - ":GSI1_SK": { S: `/${alias}` } - } - }) - ); - - if (!Array.isArray(Items)) { - return null; - } - const [item] = Items; - if (!item) { - return null; - } - const { data } = unmarshall(item); - - return data?.key ?? null; - } - - return [ - new RoutePlugin(({ onGet, context }) => { - onGet("/*", async (request, reply) => { - const fileInfo = extractFileInformation(request); - - // TODO: `root` tenant is hardcoded for now, to satisfy the basic use case. - // We need to find a way to send tenant via `x-tenant` header, when images are being requested from - // the frontend (website, admin, etc.) by alias. - const realFilename = await getFileByAlias("root", fileInfo.filename); - - if (!realFilename) { - return reply.code(404).type("text/html").send("Not Found"); - } - - const { params, object } = await getS3Object( - { ...fileInfo, filename: realFilename }, - s3, - context - ); - - if (object && isSmallObject(object)) { - console.log("This is a small file; responding with object body."); - - return reply - .headers({ - "Content-Type": object.ContentType, - "Cache-Control": `public, max-age=${DEFAULT_CACHE_MAX_AGE}`, - "x-webiny-base64-encoded": true - }) - .send(object.Body || ""); - } - - console.log("This is a large object; redirecting to a presigned URL."); - - const presignedUrl = getSignedUrl( - s3, - new GetObjectCommand({ - Bucket: params.Bucket, - Key: params.Key - }), - { - expiresIn: PRESIGNED_URL_EXPIRATION - } - ); - - // Lambda can return max 6MB of content, so if our object's size is larger, we are sending - // a 301 Redirect, redirecting the user to the public URL of the object in S3. - return reply - .code(301) - .headers({ - Location: presignedUrl, - "Cache-Control": "public, max-age=" + PRESIGNED_URL_EXPIRATION - }) - .send(""); - }); - }) - ]; -}; diff --git a/packages/api-file-manager/src/handlers/download/byExactKey.ts b/packages/api-file-manager/src/handlers/download/byExactKey.ts deleted file mode 100644 index 56347f1377b..00000000000 --- a/packages/api-file-manager/src/handlers/download/byExactKey.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { S3, getSignedUrl, GetObjectCommand } from "@webiny/aws-sdk/client-s3"; -import { getEnvironment } from "../utils"; -import { RoutePlugin } from "@webiny/handler-aws"; -import { getS3Object, isSmallObject } from "~/handlers/download/getS3Object"; -import { extractFileInformation } from "~/handlers/download/extractFileInformation"; - -const DEFAULT_CACHE_MAX_AGE = 30758400; // 1 year -const PRESIGNED_URL_EXPIRATION = 900; // 15 minutes - -const { region } = getEnvironment(); -const s3 = new S3({ region }); - -export const createDownloadFileByExactKeyPlugins = () => { - return [ - new RoutePlugin(({ onGet, context }) => { - onGet("/files/*", async (request, reply) => { - const fileInfo = extractFileInformation(request); - const { params, object } = await getS3Object(fileInfo, s3, context); - - if (object && isSmallObject(object)) { - console.log("This is a small file; responding with object body."); - return reply - .headers({ - "Content-Type": object.ContentType, - "Cache-Control": `public, max-age=${DEFAULT_CACHE_MAX_AGE}`, - "x-webiny-base64-encoded": true - }) - .send(object.Body || ""); - } - - console.log("This is a large object; redirecting to a presigned URL."); - - const presignedUrl = getSignedUrl( - s3, - new GetObjectCommand({ - Bucket: params.Bucket, - Key: params.Key - }), - { expiresIn: PRESIGNED_URL_EXPIRATION } - ); - - // Lambda can return max 6MB of content, so if our object's size is larger, we are sending - // a 301 Redirect, redirecting the user to the public URL of the object in S3. - return reply - .code(301) - .headers({ - Location: presignedUrl, - "Cache-Control": "public, max-age=" + PRESIGNED_URL_EXPIRATION - }) - .send(""); - }); - }) - ]; -}; diff --git a/packages/api-file-manager/src/handlers/download/extractFileInformation.ts b/packages/api-file-manager/src/handlers/download/extractFileInformation.ts deleted file mode 100644 index 7b129ab774a..00000000000 --- a/packages/api-file-manager/src/handlers/download/extractFileInformation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import pathLib from "path"; -import { Request } from "@webiny/handler/types"; - -export interface Options { - original?: string; - width?: string; -} -/** - * Based on given request path, extracts file key and additional options sent via query params. - */ -export const extractFileInformation = (request: Request) => { - const params = request.params as Record; - const path = params["*"]; - return { - filename: decodeURI(path), - options: request.query as Options, - extension: pathLib.extname(path) - }; -}; diff --git a/packages/api-file-manager/src/handlers/download/getS3Object.ts b/packages/api-file-manager/src/handlers/download/getS3Object.ts deleted file mode 100644 index c577e775cf5..00000000000 --- a/packages/api-file-manager/src/handlers/download/getS3Object.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { S3, GetObjectOutput } from "@webiny/aws-sdk/client-s3"; -import { Context } from "@webiny/handler/types"; -import { getObjectParams } from "~/handlers/utils"; -import loaders from "~/handlers/transform/loaders"; -import { ObjectParamsResponse } from "~/handlers/utils/getObjectParams"; -import { extractFileInformation } from "./extractFileInformation"; - -export function isSmallObject(object: GetObjectOutput) { - return (object.ContentLength ?? Number.MIN_SAFE_INTEGER) < MAX_RETURN_CONTENT_LENGTH; -} - -interface S3Object { - object?: GetObjectOutput; - params: ObjectParamsResponse; -} - -export const MAX_RETURN_CONTENT_LENGTH = 5000000; // ~4.77MB - -const getS3ObjectHead = async (s3: S3, params: ObjectParamsResponse) => { - try { - return await s3.headObject(params); - } catch (ex) { - console.log(`Something is wrong with s3.headObject(${JSON.stringify(params)})`); - throw ex; - } -}; - -export const getS3Object = async ( - fileInfo: ReturnType, - s3: S3, - context: Context -): Promise => { - const { filename, options, extension } = fileInfo; - const params = getObjectParams(filename); - - const objectHead = await getS3ObjectHead(s3, params); - const contentLength = objectHead.ContentLength ? objectHead.ContentLength : 0; - - const applyLoaders = options.original === undefined; - - if (applyLoaders) { - console.log("Applying loaders..."); - for (const loader of loaders) { - try { - const canProcess = loader.canProcess({ - context, - s3, - options, - file: { - name: filename, - extension, - contentLength - } - }); - - if (!canProcess) { - continue; - } - - return loader.process({ - context, - s3, - options, - file: { - name: filename, - extension, - contentLength - } - }); - } catch (err) { - console.log("ERROR WHILE PROCESSING A LOADER"); - console.log(err); - } - } - } else { - console.log("Skipping loaders."); - } - - // If no processors handled the file request, just return the S3 object taking its size into consideration. - let object; - if (contentLength < MAX_RETURN_CONTENT_LENGTH) { - object = await s3.getObject(params); - } - - return { object, params }; -}; diff --git a/packages/api-file-manager/src/handlers/download/index.ts b/packages/api-file-manager/src/handlers/download/index.ts deleted file mode 100644 index 98825ed84f0..00000000000 --- a/packages/api-file-manager/src/handlers/download/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./byExactKey"; -export * from "./byAlias"; diff --git a/packages/api-file-manager/src/handlers/transform/managers/imageManager.ts b/packages/api-file-manager/src/handlers/manage/imageManager.ts similarity index 95% rename from packages/api-file-manager/src/handlers/transform/managers/imageManager.ts rename to packages/api-file-manager/src/handlers/manage/imageManager.ts index 53bf1ed6387..6a875f93c01 100644 --- a/packages/api-file-manager/src/handlers/transform/managers/imageManager.ts +++ b/packages/api-file-manager/src/handlers/manage/imageManager.ts @@ -1,8 +1,8 @@ import { dirname } from "path"; import { S3 } from "@webiny/aws-sdk/client-s3"; import { getObjectParams, getEnvironment } from "~/handlers/utils"; -import * as newUtils from "../utils"; -import * as legacyUtils from "../legacyUtils"; +import * as newUtils from "./utils"; +import * as legacyUtils from "./legacyUtils"; const isLegacyKey = (key: string) => { return !key.includes("/"); @@ -17,7 +17,7 @@ export interface ImageManagerProcessParams { key: string; extension: string; } -export default { +export const imageManager = { canProcess: (params: ImageManagerCanProcessParams) => { const { key, extension } = params; const utils = key.includes("/") ? newUtils : legacyUtils; diff --git a/packages/api-file-manager/src/handlers/manage/index.ts b/packages/api-file-manager/src/handlers/manage/index.ts index b7bbb77357b..0f42b744ad7 100644 --- a/packages/api-file-manager/src/handlers/manage/index.ts +++ b/packages/api-file-manager/src/handlers/manage/index.ts @@ -1,9 +1,11 @@ import path from "path"; import { S3 } from "@webiny/aws-sdk/client-s3"; import { getEnvironment } from "../utils"; -import managers from "../transform/managers"; +import { imageManager } from "./imageManager"; import { S3EventHandler } from "@webiny/handler-aws"; +const managers = [imageManager]; + /** * This handler must be run through @webiny/handler-aws/s3 */ diff --git a/packages/api-file-manager/src/handlers/manage/legacyUtils.ts b/packages/api-file-manager/src/handlers/manage/legacyUtils.ts new file mode 100644 index 00000000000..94458eb6f99 --- /dev/null +++ b/packages/api-file-manager/src/handlers/manage/legacyUtils.ts @@ -0,0 +1,40 @@ +import objectHash from "object-hash"; + +const SUPPORTED_IMAGES = [".jpg", ".jpeg", ".png", ".svg", ".gif"]; +const SUPPORTED_TRANSFORMABLE_IMAGES = [".jpg", ".jpeg", ".png"]; + +const OPTIMIZED_TRANSFORMED_IMAGE_PREFIX = "img-o-t-"; +const OPTIMIZED_IMAGE_PREFIX = "img-o-"; + +const getOptimizedImageKeyPrefix = (key: string): string => { + return `${OPTIMIZED_IMAGE_PREFIX}${objectHash(key)}-`; +}; + +const getOptimizedTransformedImageKeyPrefix = (key: string): string => { + return `${OPTIMIZED_TRANSFORMED_IMAGE_PREFIX}${objectHash(key)}-`; +}; + +interface GetImageKeyParams { + key: string; + transformations?: any; +} + +const getImageKey = ({ key, transformations }: GetImageKeyParams): string => { + if (!transformations) { + const prefix = getOptimizedImageKeyPrefix(key); + return prefix + key; + } + + const prefix = getOptimizedTransformedImageKeyPrefix(key); + return `${prefix}${objectHash(transformations)}-${key}`; +}; + +export { + SUPPORTED_IMAGES, + SUPPORTED_TRANSFORMABLE_IMAGES, + OPTIMIZED_TRANSFORMED_IMAGE_PREFIX, + OPTIMIZED_IMAGE_PREFIX, + getImageKey, + getOptimizedImageKeyPrefix, + getOptimizedTransformedImageKeyPrefix +}; diff --git a/packages/api-file-manager/src/handlers/transform/utils.ts b/packages/api-file-manager/src/handlers/manage/utils.ts similarity index 100% rename from packages/api-file-manager/src/handlers/transform/utils.ts rename to packages/api-file-manager/src/handlers/manage/utils.ts diff --git a/packages/api-file-manager/src/handlers/transform/index.ts b/packages/api-file-manager/src/handlers/transform/index.ts deleted file mode 100644 index f9d28950438..00000000000 --- a/packages/api-file-manager/src/handlers/transform/index.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { S3, GetObjectOutput } from "@webiny/aws-sdk/client-s3"; -import { transformImage } from "./transformImage"; -import optimizeImage from "./optimizeImage"; -import { getEnvironment, getObjectParams } from "../utils"; -import * as newUtils from "./utils"; -import * as legacyUtils from "./legacyUtils"; -import { TransformHandlerEventPayload } from "~/handlers/types"; -import { createEvent } from "@webiny/handler"; -import type { Readable } from "stream"; - -export const createTransformFilePlugins = () => { - return [ - createEvent(async ({ payload }) => { - const { body } = payload; - const { key, transformations } = body; - try { - const env = getEnvironment(); - const s3 = new S3({ region: env.region }); - - let optimizedImageObject: GetObjectOutput; - - const utils = key.includes("/") ? newUtils : legacyUtils; - - const params = { - initial: getObjectParams(key), - optimized: getObjectParams(utils.getImageKey({ key })), - optimizedTransformed: getObjectParams( - utils.getImageKey({ key, transformations }) - ) - }; - - // 1. Get optimized image. - try { - optimizedImageObject = await s3.getObject(params.optimized); - } catch (e) { - // If not found, try to create it by loading the initially uploaded image. - optimizedImageObject = await s3.getObject(params.initial); - - await s3.putObject({ - ...params.optimized, - ContentType: optimizedImageObject.ContentType, - Body: await optimizeImage( - optimizedImageObject.Body as Readable, - optimizedImageObject.ContentType as string - ) - }); - - optimizedImageObject = await s3.getObject(params.optimized); - } - - // 2. If no transformations requested, just exit. - if (!transformations) { - return { - error: false, - message: "" - }; - } - - // 3. If transformations requested, apply them in save it into the bucket. - const isAnimated = key.endsWith(".gif") || key.endsWith(".webp"); - - await s3.putObject({ - ...params.optimizedTransformed, - ContentType: optimizedImageObject.ContentType, - Body: await transformImage( - optimizedImageObject.Body as Readable, - transformations, - { - animated: isAnimated - } - ) - }); - - return { - error: false, - message: "" - }; - } catch (ex) { - console.error( - JSON.stringify({ - message: ex.message, - code: ex.code, - data: ex.data - }) - ); - return { - error: true, - message: ex.message - }; - } - }) - ]; -}; diff --git a/packages/api-file-manager/src/handlers/transform/loaders/imageLoader.ts b/packages/api-file-manager/src/handlers/transform/loaders/imageLoader.ts deleted file mode 100644 index 0e9c0fa5648..00000000000 --- a/packages/api-file-manager/src/handlers/transform/loaders/imageLoader.ts +++ /dev/null @@ -1,109 +0,0 @@ -import sanitizeImageTransformations from "./sanitizeImageTransformations"; -import { getObjectParams } from "../../utils"; -import * as newUtils from "../utils"; -import * as legacyUtils from "../legacyUtils"; -import { ClientContext } from "@webiny/handler-client/types"; -import { S3 } from "@webiny/aws-sdk/client-s3"; - -const IMAGE_TRANSFORMER_FUNCTION = process.env.IMAGE_TRANSFORMER_FUNCTION as string; - -interface TransformerParams { - context: ClientContext; - key: string; - transformations?: { - width?: number; - }; -} - -const callImageTransformerLambda = async ({ key, transformations, context }: TransformerParams) => { - return await context.handlerClient.invoke({ - name: IMAGE_TRANSFORMER_FUNCTION, - payload: { - body: { - key, - transformations - } - } - }); -}; -interface File { - extension: string; - name: string; - contentLength: number; -} -interface Options { - width?: string; -} -export interface CanProcessParams { - s3: S3; - file: File; - options?: Options; - context: ClientContext; -} -export interface ProcessParams { - s3: S3; - file: File; - options?: Options; - context: ClientContext; -} -export default { - canProcess: ({ file }: CanProcessParams) => { - const utils = file.name.includes("/") ? newUtils : legacyUtils; - return utils.SUPPORTED_IMAGES.includes(file.extension); - }, - async process({ s3, file, options, context }: ProcessParams) { - // Loaders must return {object, params} object. - let objectParams; - - const utils = file.name.includes("/") ? newUtils : legacyUtils; - - const transformations = sanitizeImageTransformations(options); - - if (transformations && utils.SUPPORTED_TRANSFORMABLE_IMAGES.includes(file.extension)) { - objectParams = getObjectParams(utils.getImageKey({ key: file.name, transformations })); - try { - return { - object: await s3.getObject(objectParams), - params: objectParams - }; - } catch (e) { - const imageTransformerLambdaResponse = await callImageTransformerLambda({ - key: file.name, - transformations, - context - }); - - if (imageTransformerLambdaResponse.error) { - throw Error(imageTransformerLambdaResponse.message); - } - - return { - object: await s3.getObject(objectParams), - params: objectParams - }; - } - } - - objectParams = getObjectParams(utils.getImageKey({ key: file.name })); - try { - return { - object: await s3.getObject(objectParams), - params: objectParams - }; - } catch (e) { - const imageTransformerLambdaResponse = await callImageTransformerLambda({ - key: file.name, - context - }); - - if (imageTransformerLambdaResponse.error) { - throw Error(imageTransformerLambdaResponse.message); - } - - return { - object: await s3.getObject(objectParams), - params: objectParams - }; - } - } -}; diff --git a/packages/api-file-manager/src/handlers/transform/loaders/index.ts b/packages/api-file-manager/src/handlers/transform/loaders/index.ts deleted file mode 100644 index fcf21456b2b..00000000000 --- a/packages/api-file-manager/src/handlers/transform/loaders/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import imageLoader from "./imageLoader"; - -export default [imageLoader]; diff --git a/packages/api-file-manager/src/handlers/transform/loaders/sanitizeImageTransformations.ts b/packages/api-file-manager/src/handlers/transform/loaders/sanitizeImageTransformations.ts deleted file mode 100644 index cbf27487676..00000000000 --- a/packages/api-file-manager/src/handlers/transform/loaders/sanitizeImageTransformations.ts +++ /dev/null @@ -1,51 +0,0 @@ -const SUPPORTED_IMAGE_RESIZE_WIDTHS: number[] = [100, 300, 500, 750, 1000, 1500, 2500]; - -export interface SanitizeImageArgs { - width?: string; -} - -export interface SanitizeImageTransformations { - width: number; -} -/** - * Takes only allowed transformations into consideration, and discards the rest. - */ -export default (args?: SanitizeImageArgs): SanitizeImageTransformations | null => { - const transformations: Partial = {}; - - if (!args || !args.width) { - return null; - } - const width = parseInt(args.width); - if (width <= 0) { - return null; - } - transformations.width = SUPPORTED_IMAGE_RESIZE_WIDTHS[0]; - let i = SUPPORTED_IMAGE_RESIZE_WIDTHS.length; - while (i >= 0) { - if (width === SUPPORTED_IMAGE_RESIZE_WIDTHS[i]) { - transformations.width = SUPPORTED_IMAGE_RESIZE_WIDTHS[i]; - break; - } - - if (width > SUPPORTED_IMAGE_RESIZE_WIDTHS[i]) { - // Use next larger width. If there isn't any, use current. - transformations.width = SUPPORTED_IMAGE_RESIZE_WIDTHS[i + 1]; - if (!transformations.width) { - transformations.width = SUPPORTED_IMAGE_RESIZE_WIDTHS[i]; - } - break; - } - - i--; - } - - if (Object.keys(transformations).length > 0) { - /** - * It is safe to cast. - */ - return transformations as SanitizeImageTransformations; - } - - return null; -}; diff --git a/packages/api-file-manager/src/handlers/transform/managers/index.ts b/packages/api-file-manager/src/handlers/transform/managers/index.ts deleted file mode 100644 index e012853409b..00000000000 --- a/packages/api-file-manager/src/handlers/transform/managers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import imageManager from "./imageManager"; - -export default [imageManager]; diff --git a/packages/api-file-manager/src/handlers/transform/optimizeImage.ts b/packages/api-file-manager/src/handlers/transform/optimizeImage.ts deleted file mode 100644 index d4c7216bed6..00000000000 --- a/packages/api-file-manager/src/handlers/transform/optimizeImage.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Sharp is included in the AWS Lambda layer - */ -import sharp from "sharp"; -import type { Readable } from "stream"; - -export default async (stream: Readable, type: string): Promise => { - switch (type) { - case "image/png": { - const optimizedImage = sharp() - .resize({ width: 2560, withoutEnlargement: true, fit: "inside" }) - .png({ compressionLevel: 9, adaptiveFiltering: true, force: true }) - .withMetadata(); - - return await stream.pipe(optimizedImage).toBuffer(); - } - case "image/jpeg": - case "image/jpg": { - const optimizedImage = sharp() - .resize({ width: 2560, withoutEnlargement: true, fit: "inside" }) - .toFormat("jpeg", { quality: 90 }); - - return await stream.pipe(optimizedImage).toBuffer(); - } - default: - const chunks = []; - - for await (const chunk of stream) { - chunks.push(chunk); - } - - return Buffer.concat(chunks); - } -}; diff --git a/packages/api-file-manager/src/handlers/transform/transformImage.ts b/packages/api-file-manager/src/handlers/transform/transformImage.ts deleted file mode 100644 index de25728cedd..00000000000 --- a/packages/api-file-manager/src/handlers/transform/transformImage.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Sharp is included in the AWS Lambda layer - */ -import sharp from "sharp"; -import type { Readable } from "stream"; - -interface Transformation { - width: number; -} - -export interface TransformOptions { - animated?: boolean; -} - -/** - * Only processing "width" at the moment. - * Check "sanitizeImageTransformations.js" to allow additional image processing transformations. - */ -export const transformImage = async ( - stream: Readable, - transformations: Transformation, - options: TransformOptions = {} -): Promise => { - const { width } = transformations; - const transformedImage = sharp({ animated: options.animated ?? false }).resize({ - width - }); - - return await stream.pipe(transformedImage).toBuffer(); -}; diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index 7f047b3de3f..b9bcf7eb23c 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -2,9 +2,12 @@ import { ContextPlugin } from "@webiny/api"; import { FileManagerConfig } from "~/createFileManager"; import { FileManagerContext } from "~/types"; import { FileManagerContextSetup } from "./FileManagerContextSetup"; +import { setupAssetDelivery, AssetDeliveryParams } from "./delivery/setupAssetDelivery"; import { createGraphQLSchemaPlugin } from "./graphql"; +export * from "./modelModifier/CmsModelModifier"; export * from "./plugins"; +export * from "./delivery"; export const createFileManagerContext = ({ storageOperations @@ -23,4 +26,6 @@ export const createFileManagerGraphQL = () => { return createGraphQLSchemaPlugin(); }; -export * from "./modelModifier/CmsModelModifier"; +export const createAssetDelivery = (config: AssetDeliveryParams) => { + return setupAssetDelivery(config); +}; diff --git a/packages/api-file-manager/src/types.ts b/packages/api-file-manager/src/types.ts index 32284fecd9d..444f68634ed 100644 --- a/packages/api-file-manager/src/types.ts +++ b/packages/api-file-manager/src/types.ts @@ -7,9 +7,11 @@ import { FileLifecycleEvents } from "./types/file.lifecycle"; import { CreatedBy, File } from "./types/file"; import { Topic } from "@webiny/pubsub/types"; import { CmsContext } from "@webiny/api-headless-cms/types"; +import { Context as TasksContext } from "@webiny/tasks/types"; export * from "./types/file.lifecycle"; export * from "./types/file"; +export * from "./types/file"; export interface FileManagerContextObject extends FilesCRUD, SettingsCRUD, SystemCRUD { storage: FileStorage; @@ -20,7 +22,8 @@ export interface FileManagerContext SecurityContext, TenancyContext, I18NContext, - CmsContext { + CmsContext, + TasksContext { fileManager: FileManagerContextObject; } diff --git a/packages/api-file-manager/src/types/file.ts b/packages/api-file-manager/src/types/file.ts index d41cd175bc6..6587b8fbf06 100644 --- a/packages/api-file-manager/src/types/file.ts +++ b/packages/api-file-manager/src/types/file.ts @@ -1,3 +1,11 @@ +type PublicAccess = { + type: "public"; +}; + +type PrivateAuthenticatedAccess = { + type: "private-authenticated"; +}; + export interface File { id: string; key: string; @@ -5,6 +13,7 @@ export interface File { type: string; name: string; meta: Record; + accessControl?: PublicAccess | PrivateAuthenticatedAccess; location: { folderId: string; }; diff --git a/packages/api-file-manager/tsconfig.build.json b/packages/api-file-manager/tsconfig.build.json index 2af0086413c..5c9f6f9f3ed 100644 --- a/packages/api-file-manager/tsconfig.build.json +++ b/packages/api-file-manager/tsconfig.build.json @@ -10,10 +10,10 @@ { "path": "../error/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, - { "path": "../handler-client/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" }, { "path": "../validation/tsconfig.build.json" }, { "path": "../api-i18n/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" } diff --git a/packages/api-file-manager/tsconfig.json b/packages/api-file-manager/tsconfig.json index 5730bddf5e3..d09543209ca 100644 --- a/packages/api-file-manager/tsconfig.json +++ b/packages/api-file-manager/tsconfig.json @@ -10,10 +10,10 @@ { "path": "../error" }, { "path": "../handler" }, { "path": "../handler-aws" }, - { "path": "../handler-client" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, { "path": "../pubsub" }, + { "path": "../tasks" }, { "path": "../validation" }, { "path": "../api-i18n" }, { "path": "../utils" } @@ -41,14 +41,14 @@ "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], - "@webiny/handler-client/*": ["../handler-client/src/*"], - "@webiny/handler-client": ["../handler-client/src"], "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], "@webiny/pubsub/*": ["../pubsub/src/*"], "@webiny/pubsub": ["../pubsub/src"], + "@webiny/tasks/*": ["../tasks/src/*"], + "@webiny/tasks": ["../tasks/src"], "@webiny/validation/*": ["../validation/src/*"], "@webiny/validation": ["../validation/src"], "@webiny/api-i18n/*": ["../api-i18n/src/*"], diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts index 3932f63471e..bc95a018235 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -62,13 +62,18 @@ const plugin: GraphQLSchemaPlugin = { input FbFormRuleInput { title: String - action: String - chain: String + action: FbFormRuleActionInput + matchAll: Boolean id: String conditions: [FbFormConditionInput] isValid: Boolean } + input FbFormRuleActionInput { + type: String + value: String + } + input FbFormConditionInput { id: String fieldName: String @@ -101,13 +106,18 @@ const plugin: GraphQLSchemaPlugin = { type FbFormRuleType { title: String - action: String - chain: String + action: FbFormRuleActionType + matchAll: Boolean id: String conditions: [FbFormConditionType] isValid: Boolean } + type FbFormRuleActionType { + type: String + value: String + } + type FbFormConditionType { id: String fieldName: String diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index fe8cd245066..f2d99ef2947 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -22,9 +22,14 @@ interface FbFormStep { rules: FbFormRule[]; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/api-headless-cms-ddb-es/__tests__/plugins/elasticsearch/indexing/objectIndexing.test.ts b/packages/api-headless-cms-ddb-es/__tests__/plugins/elasticsearch/indexing/objectIndexing.test.ts index d9aac09a724..6468d2079b7 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/plugins/elasticsearch/indexing/objectIndexing.test.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/plugins/elasticsearch/indexing/objectIndexing.test.ts @@ -160,6 +160,7 @@ const expectedRawValue = { } ], settingsStorageId: { + optionsStorageId: [{}, {}], snippetStorageId: [ { tag: "p", diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts index 34f4ea39042..150d62fc9e8 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts @@ -39,34 +39,6 @@ export const createEntryEntity = (params: CreateEntryEntityParams): Entity type: "string" }, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - createdBy: { - type: "map" - }, - ownedBy: { - type: "map" - }, - modifiedBy: { - type: "map" - }, - createdOn: { - type: "string" - }, - savedOn: { - type: "string" - }, - publishedOn: { - type: "string" - }, - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ @@ -84,16 +56,16 @@ export const createEntryEntity = (params: CreateEntryEntityParams): Entity /** * Entry-level meta fields. 👇 */ - entryCreatedOn: { type: "string" }, - entrySavedOn: { type: "string" }, - entryModifiedOn: { type: "string" }, - entryFirstPublishedOn: { type: "string" }, - entryLastPublishedOn: { type: "string" }, - entryCreatedBy: { type: "map" }, - entrySavedBy: { type: "map" }, - entryModifiedBy: { type: "map" }, - entryFirstPublishedBy: { type: "map" }, - entryLastPublishedBy: { type: "map" }, + createdOn: { type: "string" }, + savedOn: { type: "string" }, + modifiedOn: { type: "string" }, + firstPublishedOn: { type: "string" }, + lastPublishedOn: { type: "string" }, + createdBy: { type: "map" }, + savedBy: { type: "map" }, + modifiedBy: { type: "map" }, + firstPublishedBy: { type: "map" }, + lastPublishedBy: { type: "map" }, modelId: { type: "string" diff --git a/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/index.ts b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/index.ts index e0cffa430fc..fed9eb26026 100644 --- a/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/index.ts +++ b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/index.ts @@ -4,6 +4,7 @@ import defaultFieldIndexing from "./defaultFieldIndexing"; import dateTimeIndexing from "./dateTimeIndexing"; import numberIndexing from "./numberIndexing"; import objectIndexing from "./objectIndexing"; +import { createJsonIndexing } from "./jsonIndexing"; export default () => [ dateTimeIndexing(), @@ -11,5 +12,6 @@ export default () => [ longTextIndexing(), defaultFieldIndexing(), numberIndexing(), - objectIndexing() + objectIndexing(), + createJsonIndexing() ]; diff --git a/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/jsonIndexing.ts b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/jsonIndexing.ts new file mode 100644 index 00000000000..bf129a8007e --- /dev/null +++ b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/jsonIndexing.ts @@ -0,0 +1,17 @@ +import { CmsModelFieldToElasticsearchPlugin } from "~/types"; + +export const createJsonIndexing = (): CmsModelFieldToElasticsearchPlugin => { + return { + type: "cms-model-field-to-elastic-search", + name: "cms-model-field-to-elastic-search-json", + fieldType: "json", + toIndex({ value }) { + return { + rawValue: value + }; + }, + fromIndex({ rawValue }) { + return rawValue; + } + }; +}; diff --git a/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/objectIndexing.ts b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/objectIndexing.ts index 4d532ed0b2b..5d99d1d9dde 100644 --- a/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/objectIndexing.ts +++ b/packages/api-headless-cms-ddb-es/src/elasticsearch/indexing/objectIndexing.ts @@ -178,13 +178,9 @@ export default (): CmsModelFieldToElasticsearchPlugin => ({ plugins, fields }); - if (Object.keys(value).length > 0) { - result.value.push(value); - } - if (Object.keys(rawValue).length > 0) { - result.rawValue.push(rawValue); - } + result.value.push(value); + result.rawValue.push(rawValue); } return { diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/fields.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/fields.ts index fbd1f6304c1..b70f8676ff3 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/fields.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/fields.ts @@ -100,77 +100,6 @@ const createSystemFields = (): ModelFields => { parents: [] }, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - savedOn: { - type: "date", - unmappedType: "date", - keyword: false, - systemField: true, - searchable: true, - sortable: true, - field: createSystemField({ - storageId: "savedOn", - fieldId: "savedOn", - type: "datetime", - settings: { - type: "dateTimeWithoutTimezone" - } - }), - parents: [] - }, - createdOn: { - type: "date", - unmappedType: "date", - keyword: false, - systemField: true, - searchable: true, - sortable: true, - field: createSystemField({ - storageId: "createdOn", - fieldId: "createdOn", - type: "text", - settings: { - type: "dateTimeWithoutTimezone" - } - }), - parents: [] - }, - createdBy: { - type: "text", - unmappedType: undefined, - systemField: true, - searchable: true, - sortable: false, - path: "createdBy.id", - field: createSystemField({ - storageId: "createdBy", - fieldId: "createdBy", - type: "text" - }), - parents: [] - }, - ownedBy: { - type: "text", - unmappedType: undefined, - systemField: true, - searchable: true, - sortable: false, - path: "ownedBy.id", - field: createSystemField({ - storageId: "ownedBy", - fieldId: "ownedBy", - type: "text" - }), - parents: [] - }, - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ ...onMetaFields, ...byMetaFields, 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 17e81f3d00a..a6fb2c37dd6 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 @@ -46,7 +46,10 @@ import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; import { batchReadAll, BatchReadItem, put } from "@webiny/db-dynamodb"; import { createTransformer } from "./transformations"; import { convertEntryKeysFromStorage } from "./transformations/convertEntryKeys"; -import { pickEntryMetaFields } from "@webiny/api-headless-cms/constants"; +import { + isEntryLevelEntryMetaField, + pickEntryMetaFields +} from "@webiny/api-headless-cms/constants"; interface ElasticsearchDbRecord { index: string; @@ -434,9 +437,10 @@ export const createEntriesStorageOperations = ( * If not updating latest revision, we still want to update the latest revision's * entry-level meta fields to match the current revision's entry-level meta fields. */ - const updatedEntryLevelMetaFields = pickEntryMetaFields(entry, field => { - return field.startsWith("entry"); - }); + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); const updatedLatestStorageEntry = { ...latestStorageEntry, @@ -1205,7 +1209,6 @@ export const createEntriesStorageOperations = ( entity.putBatch({ ...previouslyPublishedEntry, status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - savedOn: entry.savedOn, TYPE: createRecordType(), PK: createPartitionKey(publishedStorageEntry), SK: createRevisionSortKey(publishedStorageEntry) @@ -1264,17 +1267,6 @@ export const createEntriesStorageOperations = ( ...latestEsEntryDataDecompressed, status: CONTENT_ENTRY_STATUS.PUBLISHED, locked: true, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - savedOn: entry.savedOn, - publishedOn: entry.publishedOn, - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ ...updatedMetaFields } }); @@ -1288,9 +1280,10 @@ export const createEntriesStorageOperations = ( }) ); } else { - const updatedEntryLevelMetaFields = pickEntryMetaFields(entry, field => { - return field.startsWith("entry"); - }); + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); const updatedLatestStorageEntry = { ...latestStorageEntry, diff --git a/packages/api-headless-cms-ddb-es/src/types.ts b/packages/api-headless-cms-ddb-es/src/types.ts index 6920ec21691..ac2e26e4c79 100644 --- a/packages/api-headless-cms-ddb-es/src/types.ts +++ b/packages/api-headless-cms-ddb-es/src/types.ts @@ -6,6 +6,7 @@ import { CmsModel, CmsModelField, CmsModelFieldToGraphQLPlugin, + CmsModelFieldType, HeadlessCmsStorageOperations as BaseHeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; import { TableConstructor } from "@webiny/db-dynamodb/toolbox"; @@ -106,7 +107,7 @@ export interface CmsModelFieldToElasticsearchPlugin extends Plugin { * fieldType: "myField" * ``` */ - fieldType: string; + fieldType: CmsModelFieldType; /** * If you need to define a type when building an Elasticsearch query. * Check [dateTimeIndexing](https://github.com/webiny/webiny-js/blob/3074165701b8b45e5fc6ac2444caace7d04ada66/packages/api-headless-cms/src/content/plugins/es/indexing/dateTimeIndexing.ts) plugin for usage example. diff --git a/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/createFields.test.ts b/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/createFields.test.ts index a8a4f925dfe..6c05bf3229e 100644 --- a/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/createFields.test.ts +++ b/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/createFields.test.ts @@ -28,28 +28,6 @@ const expectedSystemFields: Record = { transform: expect.any(Function), label: "Entry ID" }, - createdOn: { - id: "createdOn", - parents: [], - type: "datetime", - storageId: "createdOn", - fieldId: "createdOn", - createPath: expect.any(Function), - system: true, - transform: expect.any(Function), - label: "Created On" - }, - savedOn: { - id: "savedOn", - parents: [], - type: "datetime", - storageId: "savedOn", - fieldId: "savedOn", - createPath: expect.any(Function), - system: true, - transform: expect.any(Function), - label: "Saved On" - }, revisionCreatedOn: { id: "revisionCreatedOn", parents: [], @@ -105,74 +83,60 @@ const expectedSystemFields: Record = { transform: expect.any(Function), label: "Revision Last Published On" }, - entryCreatedOn: { - id: "entryCreatedOn", + createdOn: { + id: "createdOn", parents: [], type: "datetime", - storageId: "entryCreatedOn", - fieldId: "entryCreatedOn", + storageId: "createdOn", + fieldId: "createdOn", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Created On" + label: "Created On" }, - entryModifiedOn: { - id: "entryModifiedOn", + modifiedOn: { + id: "modifiedOn", parents: [], type: "datetime", - storageId: "entryModifiedOn", - fieldId: "entryModifiedOn", + storageId: "modifiedOn", + fieldId: "modifiedOn", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Modified On" + label: "Modified On" }, - entrySavedOn: { - id: "entrySavedOn", + savedOn: { + id: "savedOn", parents: [], type: "datetime", - storageId: "entrySavedOn", - fieldId: "entrySavedOn", + storageId: "savedOn", + fieldId: "savedOn", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Saved On" + label: "Saved On" }, - entryFirstPublishedOn: { - id: "entryFirstPublishedOn", + firstPublishedOn: { + id: "firstPublishedOn", parents: [], type: "datetime", - storageId: "entryFirstPublishedOn", - fieldId: "entryFirstPublishedOn", + storageId: "firstPublishedOn", + fieldId: "firstPublishedOn", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry First Published On" + label: "First Published On" }, - entryLastPublishedOn: { - id: "entryLastPublishedOn", + lastPublishedOn: { + id: "lastPublishedOn", parents: [], type: "datetime", - storageId: "entryLastPublishedOn", - fieldId: "entryLastPublishedOn", + storageId: "lastPublishedOn", + fieldId: "lastPublishedOn", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Last Published On" - }, - createdBy: { - id: "createdBy", - parents: [], - type: "plainObject", - storageId: "createdBy", - fieldId: "createdBy", - createPath: expect.any(Function), - system: true, - transform: expect.any(Function), - label: "Created By", - settings: { - path: "createdBy.id" - } + label: "Last Published On" }, revisionCreatedBy: { id: "revisionCreatedBy", @@ -244,74 +208,74 @@ const expectedSystemFields: Record = { path: "revisionLastPublishedBy.id" } }, - entryCreatedBy: { - id: "entryCreatedBy", + createdBy: { + id: "createdBy", parents: [], type: "plainObject", - storageId: "entryCreatedBy", - fieldId: "entryCreatedBy", + storageId: "createdBy", + fieldId: "createdBy", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Created By", + label: "Created By", settings: { - path: "entryCreatedBy.id" + path: "createdBy.id" } }, - entryModifiedBy: { - id: "entryModifiedBy", + modifiedBy: { + id: "modifiedBy", parents: [], type: "plainObject", - storageId: "entryModifiedBy", - fieldId: "entryModifiedBy", + storageId: "modifiedBy", + fieldId: "modifiedBy", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Modified By", + label: "Modified By", settings: { - path: "entryModifiedBy.id" + path: "modifiedBy.id" } }, - entrySavedBy: { - id: "entrySavedBy", + savedBy: { + id: "savedBy", parents: [], type: "plainObject", - storageId: "entrySavedBy", - fieldId: "entrySavedBy", + storageId: "savedBy", + fieldId: "savedBy", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Saved By", + label: "Saved By", settings: { - path: "entrySavedBy.id" + path: "savedBy.id" } }, - entryFirstPublishedBy: { - id: "entryFirstPublishedBy", + firstPublishedBy: { + id: "firstPublishedBy", parents: [], type: "plainObject", - storageId: "entryFirstPublishedBy", - fieldId: "entryFirstPublishedBy", + storageId: "firstPublishedBy", + fieldId: "firstPublishedBy", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry First Published By", + label: "First Published By", settings: { - path: "entryFirstPublishedBy.id" + path: "firstPublishedBy.id" } }, - entryLastPublishedBy: { - id: "entryLastPublishedBy", + lastPublishedBy: { + id: "lastPublishedBy", parents: [], type: "plainObject", - storageId: "entryLastPublishedBy", - fieldId: "entryLastPublishedBy", + storageId: "lastPublishedBy", + fieldId: "lastPublishedBy", createPath: expect.any(Function), system: true, transform: expect.any(Function), - label: "Entry Last Published By", + label: "Last Published By", settings: { - path: "entryLastPublishedBy.id" + path: "lastPublishedBy.id" } }, meta: { @@ -369,20 +333,6 @@ const expectedSystemFields: Record = { path: "location.folderId" } }, - ownedBy: { - id: "ownedBy", - parents: [], - type: "plainObject", - storageId: "ownedBy", - fieldId: "ownedBy", - createPath: expect.any(Function), - system: true, - transform: expect.any(Function), - label: "Owned By", - settings: { - path: "ownedBy.id" - } - }, version: { id: "version", parents: [], diff --git a/packages/api-headless-cms-ddb/src/definitions/entry.ts b/packages/api-headless-cms-ddb/src/definitions/entry.ts index a63c7dbfd42..196d681dd1c 100644 --- a/packages/api-headless-cms-ddb/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb/src/definitions/entry.ts @@ -52,61 +52,33 @@ export const createEntryEntity = (params: Params): Entity => { type: "string" }, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - createdBy: { - type: "map" - }, - ownedBy: { - type: "map" - }, - modifiedBy: { - type: "map" - }, - createdOn: { - type: "string" - }, - savedOn: { - type: "string" - }, - publishedOn: { - type: "string" - }, - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ revisionCreatedOn: { type: "string" }, - revisionSavedOn: { type: "string" }, revisionModifiedOn: { type: "string" }, + revisionSavedOn: { type: "string" }, revisionFirstPublishedOn: { type: "string" }, revisionLastPublishedOn: { type: "string" }, revisionCreatedBy: { type: "map" }, - revisionSavedBy: { type: "map" }, revisionModifiedBy: { type: "map" }, + revisionSavedBy: { type: "map" }, revisionFirstPublishedBy: { type: "map" }, revisionLastPublishedBy: { type: "map" }, /** * Entry-level meta fields. 👇 */ - entryCreatedOn: { type: "string" }, - entrySavedOn: { type: "string" }, - entryModifiedOn: { type: "string" }, - entryFirstPublishedOn: { type: "string" }, - entryLastPublishedOn: { type: "string" }, - entryCreatedBy: { type: "map" }, - entrySavedBy: { type: "map" }, - entryModifiedBy: { type: "map" }, - entryFirstPublishedBy: { type: "map" }, - entryLastPublishedBy: { type: "map" }, + createdOn: { type: "string" }, + modifiedOn: { type: "string" }, + savedOn: { type: "string" }, + firstPublishedOn: { type: "string" }, + lastPublishedOn: { type: "string" }, + createdBy: { type: "map" }, + modifiedBy: { type: "map" }, + savedBy: { type: "map" }, + firstPublishedBy: { type: "map" }, + lastPublishedBy: { type: "map" }, version: { type: "number" diff --git a/packages/api-headless-cms-ddb/src/operations/entry/filtering/systemFields.ts b/packages/api-headless-cms-ddb/src/operations/entry/filtering/systemFields.ts index 387abf6ef14..cdab9d139c2 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/filtering/systemFields.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/filtering/systemFields.ts @@ -47,49 +47,7 @@ export const createSystemFields = (): Field[] => { fieldId: "entryId", label: "Entry ID" }, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - { - id: "createdOn", - type: "datetime", - storageId: "createdOn", - fieldId: "createdOn", - label: "Created On" - }, - { - id: "savedOn", - type: "datetime", - storageId: "savedOn", - fieldId: "savedOn", - label: "Saved On" - }, - { - id: "createdBy", - type: "plainObject", - storageId: "createdBy", - fieldId: "createdBy", - label: "Created By", - settings: { - path: "createdBy.id" - } - }, - { - id: "ownedBy", - type: "plainObject", - storageId: "ownedBy", - fieldId: "ownedBy", - label: "Owned By", - settings: { - path: "ownedBy.id" - } - }, - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ ...onMetaFields, ...byMetaFields, 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 8709a1ce907..a9230cc2801 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -36,7 +36,10 @@ import { createFields } from "~/operations/entry/filtering/createFields"; import { filter, sort } from "~/operations/entry/filtering"; import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; import { CmsEntryStorageOperations } from "~/types"; -import { pickEntryMetaFields } from "@webiny/api-headless-cms/constants"; +import { + isEntryLevelEntryMetaField, + pickEntryMetaFields +} from "@webiny/api-headless-cms/constants"; const createType = (): string => { return "cms.entry"; @@ -360,9 +363,10 @@ export const createEntriesStorageOperations = ( * If not updating latest revision, we still want to update the latest revision's * entry-level meta fields to match the current revision's entry-level meta fields. */ - const updatedEntryLevelMetaFields = pickEntryMetaFields(entry, field => { - return field.startsWith("entry"); - }); + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); /** * First we update the regular DynamoDB table. Two updates are needed: @@ -1069,9 +1073,10 @@ export const createEntriesStorageOperations = ( // 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, field => { - return field.startsWith("entry"); - }); + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); // 1. Update actual revision record. items.push( @@ -1202,9 +1207,10 @@ export const createEntriesStorageOperations = ( // If the unpublished 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, field => { - return field.startsWith("entry"); - }); + const updatedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); // 1. Update actual revision record. items.push( diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts index f318a932840..e71af68ae09 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts @@ -343,7 +343,7 @@ describe("content entry validation", () => { ], releaseDate: "2021-01-01", runningTime: "22:45", - lastPublishedOn: "2023-01-01T00:00:00.000+00:00", + xyzPublishedOn: "2023-01-01T00:00:00.000+00:00", image: "https://webiny.com/image.png", category: { id: "category-1", @@ -362,7 +362,7 @@ describe("content entry validation", () => { ], nestedReleaseDate: "2022-01-01", nestedRunningTime: "23:55", - nestedLastPublishedOn: "2022-01-01T00:00:00.000+02:00", + nestedXyzPublishedOn: "2022-01-01T00:00:00.000+02:00", nestedImage: "https://webiny.com/image2.png", nestedCategory: { id: "category-2", @@ -383,7 +383,7 @@ describe("content entry validation", () => { ], dzReleaseDate: "2021-01-01", dzRunningTime: "21:55", - dzLastPublishedOn: "2021-01-01T00:00:00.000+02:00", + dzXyzPublishedOn: "2021-01-01T00:00:00.000+02:00", dzImage: "https://webiny.com/image2.png", dzCategory: { id: "category-3", @@ -402,7 +402,7 @@ describe("content entry validation", () => { ], dzNestedReleaseDate: "2021-01-01", dzNestedRunningTime: "21:55", - dzNestedLastPublishedOn: "2021-01-01T00:00:00.000+02:00", + dzNestedXyzPublishedOn: "2021-01-01T00:00:00.000+02:00", dzNestedImage: "https://webiny.com/image2.png", dzNestedCategory: { id: "category-4", @@ -424,7 +424,7 @@ describe("content entry validation", () => { ], multiValueReleaseDate: "2021-01-01", multiValueRunningTime: "21:55", - multiValueLastPublishedOn: "2021-01-01T00:00:00.000+02:00", + multiValueXyzPublishedOn: "2021-01-01T00:00:00.000+02:00", multiValueImage: "https://webiny.com/image2.png", multiValueCategory: { id: "category-11", diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/mocks/field.dateTime.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/mocks/field.dateTime.ts index 1cbb3f9f6a0..e4ee48e6cf8 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/mocks/field.dateTime.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/mocks/field.dateTime.ts @@ -2,10 +2,10 @@ import { createField, CreateFieldInput } from "./fields"; export const createDateTimeField = (params: Partial = {}) => { return createField({ - id: "lastPublishedOn", + id: "xyzPublishedOn", type: "datetime", - fieldId: "lastPublishedOn", - label: "Last published on", + fieldId: "xyzPublishedOn", + label: "Xyz published on", settings: { type: "dateTimeWithTimezone" }, diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts index d1d5334fda2..fa8a2bce373 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFields.test.ts @@ -24,23 +24,18 @@ describe("Content entries - Entry Meta Fields", () => { let { data: entriesList } = await manageApiIdentityA.listTestEntries(); const matchObject1 = { - createdOn: expect.toBeDateString(), - createdBy: identityA, - modifiedBy: null, - ownedBy: identityA, - savedOn: expect.toBeDateString(), revisionCreatedOn: expect.toBeDateString(), revisionSavedOn: expect.toBeDateString(), revisionModifiedOn: null, revisionCreatedBy: identityA, revisionSavedBy: identityA, revisionModifiedBy: null, - entryCreatedOn: expect.toBeDateString(), - entrySavedOn: expect.toBeDateString(), - entryModifiedOn: null, - entryCreatedBy: identityA, - entrySavedBy: identityA, - entryModifiedBy: null + createdOn: expect.toBeDateString(), + savedOn: expect.toBeDateString(), + modifiedOn: null, + createdBy: identityA, + savedBy: identityA, + modifiedBy: null }; expect(revision1).toMatchObject(matchObject1); @@ -56,21 +51,14 @@ describe("Content entries - Entry Meta Fields", () => { ({ data: entriesList } = await manageApiIdentityA.listTestEntries()); const matchObject2 = { - // Deprecated fields. + // New fields. createdBy: identityA, createdOn: revision1.createdOn, modifiedBy: identityA, - ownedBy: identityA, + modifiedOn: expect.toBeDateString(), + savedBy: identityA, savedOn: expect.toBeDateString(), - // New fields. - entryCreatedBy: identityA, - entryCreatedOn: revision1.entryCreatedOn, - entryModifiedBy: identityA, - entryModifiedOn: expect.toBeDateString(), - entrySavedBy: identityA, - entrySavedOn: expect.toBeDateString(), - revisionCreatedBy: identityA, revisionCreatedOn: revision1.createdOn, revisionModifiedBy: identityA, @@ -91,11 +79,6 @@ describe("Content entries - Entry Meta Fields", () => { ({ data: entriesList } = await manageApiIdentityA.listTestEntries()); const matchObject3 = { - createdOn: expect.toBeDateString(), - createdBy: identityA, - modifiedBy: null, - ownedBy: identityA, - savedOn: expect.toBeDateString(), revisionCreatedOn: expect.toBeDateString(), revisionSavedOn: expect.toBeDateString(), @@ -108,17 +91,17 @@ describe("Content entries - Entry Meta Fields", () => { // Note that these are null, since, on a revision-level, an update has not been made yet. revisionModifiedBy: null, - entryCreatedOn: expect.toBeDateString(), - entrySavedOn: expect.toBeDateString(), + createdOn: expect.toBeDateString(), + savedOn: expect.toBeDateString(), // Note that these are not null, since, on an entry-level, an update has been made. - entryModifiedOn: expect.toBeDateString(), + modifiedOn: expect.toBeDateString(), - entryCreatedBy: identityA, - entrySavedBy: identityA, + createdBy: identityA, + savedBy: identityA, // Note that these are not null, since, on an entry-level, an update has been made. - entryModifiedBy: identityA + modifiedBy: identityA }; expect(revision2).toMatchObject(matchObject3); @@ -134,16 +117,16 @@ describe("Content entries - Entry Meta Fields", () => { expect(entriesList[0].revisionCreatedOn > revision1.revisionCreatedOn).toBe(true); // Entry-level `createdOn` meta field should remain the same. - expect(revision2.entryCreatedOn).toBe(revision1.entryCreatedOn); - expect(entriesList[0].entryCreatedOn).toBe(revision1.entryCreatedOn); + expect(revision2.createdOn).toBe(revision1.createdOn); + expect(entriesList[0].createdOn).toBe(revision1.createdOn); // Entry-level `savedOn` and `modifiedOn` meta fields should change. // It is true that previous revision's entry-level fields are not updated, but that's // fine. When updating entry-level meta fields, we only care about latest revisions. - expect(revision2.entrySavedOn > revision1.entrySavedOn).toBe(true); - expect(revision2.entryModifiedOn > revision1.entryModifiedOn).toBe(true); - expect(entriesList[0].entrySavedOn > revision1.entrySavedOn).toBe(true); - expect(entriesList[0].entryModifiedOn > revision1.entryModifiedOn).toBe(true); + expect(revision2.savedOn > revision1.savedOn).toBe(true); + expect(revision2.modifiedOn > revision1.modifiedOn).toBe(true); + expect(entriesList[0].savedOn > revision1.savedOn).toBe(true); + expect(entriesList[0].modifiedOn > revision1.modifiedOn).toBe(true); }); test("updating a previous revision should update entry-level meta fields", async () => { @@ -171,31 +154,31 @@ describe("Content entries - Entry Meta Fields", () => { // Revision 1 and 3's entry-level meta fields should be in sync. // Since listing entries uses the "latest record", we must see the same change there. - expect(revision1.entryCreatedOn).toBe(revision3.entryCreatedOn); - expect(revision1.entryCreatedBy).toEqual(revision3.entryCreatedBy); - expect(revision1.entryCreatedOn).toEqual(entriesList[0].entryCreatedOn); - expect(revision1.entryCreatedBy).toEqual(entriesList[0].entryCreatedBy); + expect(revision1.createdOn).toBe(revision3.createdOn); + expect(revision1.createdBy).toEqual(revision3.createdBy); + expect(revision1.createdOn).toEqual(entriesList[0].createdOn); + expect(revision1.createdBy).toEqual(entriesList[0].createdBy); - expect(revision1.entryModifiedOn).toBe(revision3.entryModifiedOn); - expect(revision1.entryModifiedBy).toEqual(revision3.entryModifiedBy); - expect(revision1.entryModifiedOn).toBe(entriesList[0].entryModifiedOn); - expect(revision1.entryModifiedBy).toEqual(entriesList[0].entryModifiedBy); + expect(revision1.modifiedOn).toBe(revision3.modifiedOn); + expect(revision1.modifiedBy).toEqual(revision3.modifiedBy); + expect(revision1.modifiedOn).toBe(entriesList[0].modifiedOn); + expect(revision1.modifiedBy).toEqual(entriesList[0].modifiedBy); - expect(revision1.entrySavedOn).toBe(revision3.entrySavedOn); - expect(revision1.entrySavedBy).toEqual(revision3.entrySavedBy); - expect(revision1.entrySavedOn).toBe(entriesList[0].entrySavedOn); - expect(revision1.entrySavedBy).toEqual(entriesList[0].entrySavedBy); + expect(revision1.savedOn).toBe(revision3.savedOn); + expect(revision1.savedBy).toEqual(revision3.savedBy); + expect(revision1.savedOn).toBe(entriesList[0].savedOn); + expect(revision1.savedBy).toEqual(entriesList[0].savedBy); // Except for createdOn/createdBy, revision 2's entry-level meta fields should // not be in sync. This is fine because we only care about latest revisions. - expect(revision2.entryCreatedOn).toBe(revision3.entryCreatedOn); - expect(revision2.entryCreatedBy).toEqual(revision3.entryCreatedBy); + expect(revision2.createdOn).toBe(revision3.createdOn); + expect(revision2.createdBy).toEqual(revision3.createdBy); - expect(revision2.entryModifiedOn).not.toBe(revision3.entryModifiedOn); - expect(revision2.entryModifiedBy).not.toEqual(revision3.entryModifiedBy); + expect(revision2.modifiedOn).not.toBe(revision3.modifiedOn); + expect(revision2.modifiedBy).not.toEqual(revision3.modifiedBy); - expect(revision2.entrySavedOn).not.toBe(revision3.entrySavedOn); - expect(revision2.entrySavedBy).not.toEqual(revision3.entrySavedBy); + expect(revision2.savedOn).not.toBe(revision3.savedOn); + expect(revision2.savedBy).not.toEqual(revision3.savedBy); }); test("deleting latest revision should cause the entry-level meta field values to be propagated to the new latest revision", async () => { @@ -218,19 +201,19 @@ describe("Content entries - Entry Meta Fields", () => { ({ data: revision2 } = await manageApiIdentityA.getTestEntry({ revision: revision2.id })); let { data: entriesList } = await manageApiIdentityA.listTestEntries(); - expect(revision2.entryCreatedOn).toBe(revision3.entryCreatedOn); - expect(revision2.entryCreatedBy).toEqual(revision3.entryCreatedBy); - expect(revision2.entryModifiedOn).toBe(revision3.entryModifiedOn); - expect(revision2.entryModifiedBy).toEqual(revision3.entryModifiedBy); - expect(revision2.entrySavedOn).toBe(revision3.entrySavedOn); - expect(revision2.entrySavedBy).toEqual(revision3.entrySavedBy); + expect(revision2.createdOn).toBe(revision3.createdOn); + expect(revision2.createdBy).toEqual(revision3.createdBy); + expect(revision2.modifiedOn).toBe(revision3.modifiedOn); + expect(revision2.modifiedBy).toEqual(revision3.modifiedBy); + expect(revision2.savedOn).toBe(revision3.savedOn); + expect(revision2.savedBy).toEqual(revision3.savedBy); - expect(revision2.entryCreatedOn).toBe(entriesList[0].entryCreatedOn); - expect(revision2.entryCreatedBy).toEqual(entriesList[0].entryCreatedBy); - expect(revision2.entryModifiedOn).toBe(entriesList[0].entryModifiedOn); - expect(revision2.entryModifiedBy).toEqual(entriesList[0].entryModifiedBy); - expect(revision2.entrySavedOn).toBe(entriesList[0].entrySavedOn); - expect(revision2.entrySavedBy).toEqual(entriesList[0].entrySavedBy); + expect(revision2.createdOn).toBe(entriesList[0].createdOn); + expect(revision2.createdBy).toEqual(entriesList[0].createdBy); + expect(revision2.modifiedOn).toBe(entriesList[0].modifiedOn); + expect(revision2.modifiedBy).toEqual(entriesList[0].modifiedBy); + expect(revision2.savedOn).toBe(entriesList[0].savedOn); + expect(revision2.savedBy).toEqual(entriesList[0].savedBy); // Delete revision 2 and ensure that revision 1's entry-level meta fields are propagated. await manageApiIdentityB.deleteTestEntry({ @@ -241,18 +224,18 @@ describe("Content entries - Entry Meta Fields", () => { ({ data: revision1 } = await manageApiIdentityA.getTestEntry({ revision: revision1.id })); ({ data: entriesList } = await manageApiIdentityA.listTestEntries()); - expect(revision1.entryCreatedOn).toBe(revision2.entryCreatedOn); - expect(revision1.entryCreatedBy).toEqual(revision2.entryCreatedBy); - expect(revision1.entryModifiedOn).toBe(revision2.entryModifiedOn); - expect(revision1.entryModifiedBy).toEqual(revision2.entryModifiedBy); - expect(revision1.entrySavedOn).toBe(revision2.entrySavedOn); - expect(revision1.entrySavedBy).toEqual(revision2.entrySavedBy); - - expect(revision1.entryCreatedOn).toBe(entriesList[0].entryCreatedOn); - expect(revision1.entryCreatedBy).toEqual(entriesList[0].entryCreatedBy); - expect(revision1.entryModifiedOn).toBe(entriesList[0].entryModifiedOn); - expect(revision1.entryModifiedBy).toEqual(entriesList[0].entryModifiedBy); - expect(revision1.entrySavedOn).toBe(entriesList[0].entrySavedOn); - expect(revision1.entrySavedBy).toEqual(entriesList[0].entrySavedBy); + expect(revision1.createdOn).toBe(revision2.createdOn); + expect(revision1.createdBy).toEqual(revision2.createdBy); + expect(revision1.modifiedOn).toBe(revision2.modifiedOn); + expect(revision1.modifiedBy).toEqual(revision2.modifiedBy); + expect(revision1.savedOn).toBe(revision2.savedOn); + expect(revision1.savedBy).toEqual(revision2.savedBy); + + expect(revision1.createdOn).toBe(entriesList[0].createdOn); + expect(revision1.createdBy).toEqual(entriesList[0].createdBy); + expect(revision1.modifiedOn).toBe(entriesList[0].modifiedOn); + expect(revision1.modifiedBy).toEqual(entriesList[0].modifiedBy); + expect(revision1.savedOn).toBe(entriesList[0].savedOn); + expect(revision1.savedBy).toEqual(entriesList[0].savedBy); }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts index cdedba335b5..d6c2088222a 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsOverrides.test.ts @@ -24,10 +24,10 @@ describe("Content entries - Entry Meta Fields Overrides", () => { revisionLastPublishedOn: testDate, revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: testDate, - entryLastPublishedOn: testDate, - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB + firstPublishedOn: testDate, + lastPublishedOn: testDate, + firstPublishedBy: identityB, + lastPublishedBy: identityB } }); @@ -35,16 +35,15 @@ describe("Content entries - Entry Meta Fields Overrides", () => { createdOn: expect.toBeDateString(), createdBy: identityA, modifiedBy: null, - ownedBy: identityA, savedOn: expect.toBeDateString(), revisionFirstPublishedOn: testDate, revisionLastPublishedOn: testDate, revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: testDate, - entryLastPublishedOn: testDate, - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB + firstPublishedOn: testDate, + lastPublishedOn: testDate, + firstPublishedBy: identityB, + lastPublishedBy: identityB }); }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsPublishing.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsPublishing.test.ts index 4da5c21f66a..e3f06d5b257 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsPublishing.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesOnByMetaFieldsPublishing.test.ts @@ -33,24 +33,19 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityA, revisionLastPublishedBy: identityA, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityA, - entryLastPublishedBy: identityA, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityA, + lastPublishedBy: identityA }; expect(rev).toMatchObject(matchObject); expect(entriesList[0]).toMatchObject(matchObject); expect(rev.revisionFirstPublishedOn).toBe(rev.revisionLastPublishedOn); - expect(rev.revisionFirstPublishedOn).toBe(rev.meta.publishedOn); expect(rev.revisionFirstPublishedBy).toEqual(rev.revisionLastPublishedBy); expect(entriesList[0].revisionLastPublishedOn).toBe(rev.revisionLastPublishedOn); - expect(entriesList[0].meta.publishedOn).toBe(rev.meta.publishedOn); expect(entriesList[0].revisionLastPublishedBy).toEqual(rev.revisionLastPublishedBy); }); @@ -77,13 +72,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: null, - entryLastPublishedOn: null, - entryFirstPublishedBy: null, - entryLastPublishedBy: null, - meta: { - publishedOn: null - } + firstPublishedOn: null, + lastPublishedOn: null, + firstPublishedBy: null, + lastPublishedBy: null }); const matchObject = { @@ -91,23 +83,18 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }; expect(rev2).toMatchObject(matchObject); expect(entriesList[0]).toMatchObject(matchObject); expect(rev2.revisionFirstPublishedOn).toBe(rev2.revisionLastPublishedOn); - expect(rev2.revisionFirstPublishedOn).toBe(rev2.meta.publishedOn); expect(rev2.revisionFirstPublishedBy).toEqual(rev2.revisionLastPublishedBy); expect(entriesList[0].revisionFirstPublishedOn).toBe(rev2.revisionLastPublishedOn); - expect(entriesList[0].revisionFirstPublishedOn).toBe(rev2.meta.publishedOn); expect(entriesList[0].revisionFirstPublishedBy).toEqual(rev2.revisionLastPublishedBy); }); @@ -146,13 +133,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }); // Revision 2: entry meta fields should not be populated. @@ -161,13 +145,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: null, - entryLastPublishedOn: null, - entryFirstPublishedBy: null, - entryLastPublishedBy: null, - meta: { - publishedOn: null - } + firstPublishedOn: null, + lastPublishedOn: null, + firstPublishedBy: null, + lastPublishedBy: null }); // Revision 3 (latest): only the entry-level fields should be updated. @@ -176,13 +157,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: null - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }); expect(entriesListAfterPublish1[0]).toMatchObject({ @@ -190,13 +168,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: null - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }); // Publish 2️⃣ @@ -219,20 +194,17 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { // Revision 1: entry meta fields should be populated with old values. expect(rev1AfterPublish2).toMatchObject({ - revisionFirstPublishedOn: expect.toBeDateString(), - revisionLastPublishedOn: expect.toBeDateString(), + revisionFirstPublishedOn: rev1AfterPublish1.revisionFirstPublishedOn, + revisionLastPublishedOn: rev1AfterPublish1.revisionLastPublishedOn, revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: rev1AfterPublish1.firstPublishedOn, + lastPublishedOn: rev1AfterPublish1.lastPublishedOn, + firstPublishedBy: identityB, + lastPublishedBy: identityB }); - // Nothing should happen to revision 1 and its entry-level meta fields. + // Nothing should happen to revision 1 and its meta fields. expect(pickEntryMetaFields(rev1AfterPublish1)).toEqual( pickEntryMetaFields(rev1AfterPublish2) ); @@ -243,13 +215,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityA, revisionLastPublishedBy: identityA, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityA, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityA }); // Entry-level meta fields should be updated. @@ -259,12 +228,8 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { expect( rev2AfterPublish2.revisionLastPublishedOn > rev1AfterPublish1.revisionLastPublishedOn ).toBe(true); - expect(rev2AfterPublish2.entryFirstPublishedOn).toBe( - rev1AfterPublish1.entryFirstPublishedOn - ); - expect( - rev2AfterPublish2.entryLastPublishedOn > rev1AfterPublish1.entryLastPublishedOn - ).toBe(true); + expect(rev2AfterPublish2.firstPublishedOn).toBe(rev1AfterPublish1.firstPublishedOn); + expect(rev2AfterPublish2.lastPublishedOn > rev1AfterPublish1.lastPublishedOn).toBe(true); // In the latest revision, only the entry-level fields should be updated. expect(rev3AfterPublish2).toMatchObject({ @@ -272,13 +237,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityA, - meta: { - publishedOn: null - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityA }); expect(entriesListAfterPublish2[0]).toMatchObject({ @@ -286,13 +248,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityA, - meta: { - publishedOn: null - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityA }); // Publish 3️⃣ @@ -329,13 +288,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }); // Entry-level meta fields should be updated. @@ -345,25 +301,18 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { expect( rev3AfterPublish3.revisionLastPublishedOn > rev2AfterPublish2.revisionLastPublishedOn ).toBe(true); - expect(rev3AfterPublish3.entryFirstPublishedOn).toBe( - rev2AfterPublish2.entryFirstPublishedOn - ); - expect( - rev3AfterPublish3.entryLastPublishedOn > rev2AfterPublish2.entryLastPublishedOn - ).toBe(true); + expect(rev3AfterPublish3.firstPublishedOn).toBe(rev2AfterPublish2.firstPublishedOn); + expect(rev3AfterPublish3.lastPublishedOn > rev2AfterPublish2.lastPublishedOn).toBe(true); expect(entriesListAfterPublish3[0]).toMatchObject({ revisionFirstPublishedOn: expect.toBeDateString(), revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityB, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: expect.toBeDateString(), - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityB, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityB, + lastPublishedBy: identityB }); }); @@ -393,13 +342,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityA, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: revAfterPublish1.entryFirstPublishedOn, - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityA, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: revAfterPublish1.firstPublishedOn, + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityA, + lastPublishedBy: identityB }; expect(revAfterPublish2).toMatchObject(matchObject); @@ -407,9 +353,7 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { expect( revAfterPublish2.revisionLastPublishedOn > revAfterPublish1.revisionLastPublishedOn ).toBe(true); - expect(revAfterPublish2.entryLastPublishedOn > revAfterPublish1.entryLastPublishedOn).toBe( - true - ); + expect(revAfterPublish2.lastPublishedOn > revAfterPublish1.lastPublishedOn).toBe(true); expect(entriesListAfterPublish2[0]).toMatchObject(matchObject); }); @@ -458,13 +402,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityA, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: rev1AfterPublish1.entryFirstPublishedOn, - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityA, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: rev1AfterPublish1.firstPublishedOn, + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityA, + lastPublishedBy: identityB }); const matchObject = { @@ -472,13 +413,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: null, revisionFirstPublishedBy: null, revisionLastPublishedBy: null, - entryFirstPublishedOn: rev1AfterPublish2.entryFirstPublishedOn, - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityA, - entryLastPublishedBy: identityB, - meta: { - publishedOn: null - } + firstPublishedOn: rev1AfterPublish2.firstPublishedOn, + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityA, + lastPublishedBy: identityB }; expect(rev3AfterPublish2).toMatchObject(matchObject); @@ -511,13 +449,10 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { revisionLastPublishedOn: expect.toBeDateString(), revisionFirstPublishedBy: identityA, revisionLastPublishedBy: identityB, - entryFirstPublishedOn: revAfterPublish.entryFirstPublishedOn, - entryLastPublishedOn: expect.toBeDateString(), - entryFirstPublishedBy: identityA, - entryLastPublishedBy: identityB, - meta: { - publishedOn: expect.toBeDateString() - } + firstPublishedOn: revAfterPublish.firstPublishedOn, + lastPublishedOn: expect.toBeDateString(), + firstPublishedBy: identityA, + lastPublishedBy: identityB }; expect(revAfterRepublish).toMatchObject(matchObject); @@ -526,16 +461,14 @@ describe("Content Entries - Publishing-related Entry Meta Fields", () => { expect( revAfterRepublish.revisionLastPublishedOn > revAfterPublish.revisionLastPublishedOn ).toBe(true); - expect(revAfterRepublish.entryLastPublishedOn > revAfterPublish.entryLastPublishedOn).toBe( - true - ); + expect(revAfterRepublish.lastPublishedOn > revAfterPublish.lastPublishedOn).toBe(true); expect( entriesListAfterRepublish[0].revisionLastPublishedOn > revAfterPublish.revisionLastPublishedOn ).toBe(true); - expect( - entriesListAfterRepublish[0].entryLastPublishedOn > revAfterPublish.entryLastPublishedOn - ).toBe(true); + expect(entriesListAfterRepublish[0].lastPublishedOn > revAfterPublish.lastPublishedOn).toBe( + true + ); }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts index 65f1b44a67b..94ababbf4b3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts @@ -15,9 +15,10 @@ describe("content entry custom dates", () => { it("should populate entry with custom dates", async () => { const createValues = { + status: "published", createdOn: "1997-01-01T00:00:00.000Z", savedOn: "1998-01-01T00:00:00.000Z", - publishedOn: "1999-01-01T00:00:00.000Z" + lastPublishedOn: "1999-01-01T00:00:00.000Z" }; const [createResponse] = await manager.createCategory({ data: { @@ -33,9 +34,7 @@ describe("content entry custom dates", () => { data: { savedOn: createValues.savedOn, createdOn: createValues.createdOn, - meta: { - publishedOn: createValues.publishedOn - } + lastPublishedOn: createValues.lastPublishedOn }, error: null } @@ -46,7 +45,7 @@ describe("content entry custom dates", () => { const createFromValues = { createdOn: "1997-02-01T00:00:00.000Z", savedOn: "1998-02-01T00:00:00.000Z", - publishedOn: "1999-02-01T00:00:00.000Z" + lastPublishedOn: "1999-02-01T00:00:00.000Z" }; const [createFromResponse] = await manager.createCategoryFrom({ revision: `${entryId}#0001`, @@ -60,9 +59,7 @@ describe("content entry custom dates", () => { data: { savedOn: createFromValues.savedOn, createdOn: createFromValues.createdOn, - meta: { - publishedOn: createFromValues.publishedOn - } + lastPublishedOn: createFromValues.lastPublishedOn }, error: null } @@ -72,7 +69,7 @@ describe("content entry custom dates", () => { const updateValues = { createdOn: "1997-03-01T00:00:00.000Z", savedOn: "1998-03-01T00:00:00.000Z", - publishedOn: "1999-03-01T00:00:00.000Z" + lastPublishedOn: "1999-03-01T00:00:00.000Z" }; const [updateResponse] = await manager.updateCategory({ revision: `${entryId}#0002`, @@ -86,86 +83,7 @@ describe("content entry custom dates", () => { data: { savedOn: updateValues.savedOn, createdOn: updateValues.createdOn, - meta: { - publishedOn: updateValues.publishedOn - } - }, - error: null - } - } - }); - }); - - it("should skip updating publishedOn and savedOn when user chooses to skip the update", async () => { - const createValues = { - createdOn: "1997-01-01T00:00:00.000Z", - savedOn: "1998-01-01T00:00:00.000Z", - publishedOn: "1999-01-01T00:00:00.000Z" - }; - const [createResponse] = await manager.createCategory({ - data: { - title: "Fruits", - slug: "fruits", - ...createValues - } - }); - const entryId = createResponse.data.createCategory.data.entryId; - - const [publishResponse] = await manager.publishCategory({ - revision: `${entryId}#0001` - }); - expect(publishResponse).toMatchObject({ - data: { - publishCategory: { - data: { - savedOn: expect.stringMatching(/^20/), - createdOn: createValues.createdOn, - meta: { - publishedOn: expect.stringMatching(/^20/) - } - }, - error: null - } - } - }); - - const [createFromResponse] = await manager.createCategoryFrom({ - revision: `${entryId}#0001`, - data: { - ...createValues - } - }); - expect(createFromResponse).toMatchObject({ - data: { - createCategoryFrom: { - data: { - savedOn: createValues.savedOn, - createdOn: createValues.createdOn, - meta: { - publishedOn: createValues.publishedOn - } - }, - error: null - } - } - }); - - const [publishCreatedFromResponse] = await manager.publishCategory({ - revision: `${entryId}#0002`, - options: { - updatePublishedOn: false, - updateSavedOn: false - } - }); - expect(publishCreatedFromResponse).toMatchObject({ - data: { - publishCategory: { - data: { - savedOn: createValues.savedOn, - createdOn: createValues.createdOn, - meta: { - publishedOn: createValues.publishedOn - } + lastPublishedOn: updateValues.lastPublishedOn }, error: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts index 63b58cfcf68..9487df7c3b8 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts @@ -41,9 +41,9 @@ describe("content entry custom identities", () => { data: { createCategory: { data: { - createdBy: manager.identity, + revisionCreatedBy: manager.identity, modifiedBy: null, - ownedBy: manager.identity + createdBy: manager.identity }, error: null } @@ -54,8 +54,8 @@ describe("content entry custom identities", () => { data: { title: "Category Custom Identity", slug: "category-custom-identity", - ownedBy: mockIdentityOne, - createdBy: mockIdentityTwo, + createdBy: mockIdentityOne, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree } }); @@ -64,9 +64,9 @@ describe("content entry custom identities", () => { data: { createCategory: { data: { - createdBy: mockIdentityTwo, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree, - ownedBy: mockIdentityOne + createdBy: mockIdentityOne }, error: null } @@ -86,8 +86,8 @@ describe("content entry custom identities", () => { const [createRevisionCustomIdentityResponse] = await manager.createCategoryFrom({ revision: id, data: { - ownedBy: mockIdentityOne, - createdBy: mockIdentityTwo, + createdBy: mockIdentityOne, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree } }); @@ -95,9 +95,9 @@ describe("content entry custom identities", () => { data: { createCategoryFrom: { data: { - createdBy: mockIdentityTwo, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree, - ownedBy: mockIdentityOne + createdBy: mockIdentityOne }, error: null } @@ -117,8 +117,8 @@ describe("content entry custom identities", () => { const [updateCustomIdentityResponse] = await manager.updateCategory({ revision: id, data: { - ownedBy: mockIdentityOne, - createdBy: mockIdentityTwo, + createdBy: mockIdentityOne, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree } }); @@ -126,9 +126,9 @@ describe("content entry custom identities", () => { data: { updateCategory: { data: { - createdBy: mockIdentityTwo, + revisionCreatedBy: mockIdentityTwo, modifiedBy: mockIdentityThree, - ownedBy: mockIdentityOne + createdBy: mockIdentityOne }, error: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index 44e1fd0a3d6..7a91f9d558b 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -99,7 +99,7 @@ describe("Content Entry Meta Field", () => { type: "admin", displayName: "admin" }, - ownedBy: { + savedBy: { id: "admin", type: "admin", displayName: "admin" diff --git a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts index a6db0d60363..7bf96100fb0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts @@ -22,13 +22,9 @@ const createFruitData = (counter: number): CmsEntry => { displayName: "Admin" }, tenant: "root", - publishedOn: new Date().toISOString(), + firstPublishedOn: new Date().toISOString(), + lastPublishedOn: new Date().toISOString(), locale: "en-US", - ownedBy: { - id: "admin", - displayName: "Admin", - type: "admin" - }, values: { name: `Fruit ${counter}`, isSomething: false, diff --git a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts index 3c4719aa1c3..655063c47b2 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts @@ -737,18 +737,20 @@ describe("fieldValidations", () => { id: expect.any(String), entryId: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, + savedOn: expect.stringMatching(/^20/), + firstPublishedOn: null, + lastPublishedOn: null, createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, - savedOn: expect.stringMatching(/^20/), email: defaultFruitData.email, lowerCase: defaultFruitData.lowerCase, meta: { locked: false, modelId: "fruit", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -790,18 +792,20 @@ describe("fieldValidations", () => { id: apple.id, entryId: apple.entryId, createdOn: apple.createdOn, + modifiedOn: null, + savedOn: apple.savedOn, + firstPublishedOn: null, + lastPublishedOn: null, createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, - savedOn: apple.savedOn, email: defaultFruitData.email, lowerCase: defaultFruitData.lowerCase, meta: { locked: false, modelId: "fruit", - publishedOn: null, revisions: [ { id: apple.id, diff --git a/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts b/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts index b1e4a07ce29..6beb2fe4c22 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts @@ -680,10 +680,12 @@ describe("filtering", () => { publishProduct: { data: { ...bananaProductUnpublished, + modifiedOn: expect.toBeDateString(), + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), meta: { ...bananaProductUnpublished.meta, locked: true, - publishedOn: expect.any(String), status: "published" }, savedOn: expect.any(String) @@ -1517,7 +1519,7 @@ describe("filtering", () => { }); }); - test("should filter entries by createdBy", async () => { + test("should filter entries by revisionCreatedBy", async () => { const articleManager = useArticleManageHandler(manageOpts); const articleAnotherManager = useArticleManageHandler({ ...manageOpts, @@ -1579,7 +1581,7 @@ describe("filtering", () => { const [listEq4321Response] = await articleManager.listArticles({ where: { - createdBy: "id-87654321" + revisionCreatedBy: "id-87654321" } }); @@ -1678,7 +1680,7 @@ describe("filtering", () => { }); }); - test("should filter entries by ownedBy", async () => { + test("should filter entries by createdBy", async () => { const articleManager = useArticleManageHandler(manageOpts); const articleAnotherManager = useArticleManageHandler({ ...manageOpts, @@ -1719,7 +1721,7 @@ describe("filtering", () => { const [listEq123Response] = await articleManager.listArticles({ where: { - ownedBy: "id-12345678" + createdBy: "id-12345678" } }); @@ -1739,7 +1741,7 @@ describe("filtering", () => { const [listEq4321Response] = await articleManager.listArticles({ where: { - ownedBy: "id-87654321" + createdBy: "id-87654321" } }); @@ -1759,7 +1761,7 @@ describe("filtering", () => { const [listNotEqResponse] = await articleManager.listArticles({ where: { - ownedBy_not: "id-12345678" + createdBy_not: "id-12345678" } }); @@ -1779,7 +1781,7 @@ describe("filtering", () => { const [listInResponse] = await articleManager.listArticles({ where: { - ownedBy_in: ["id-12345678"] + createdBy_in: ["id-12345678"] } }); @@ -1799,7 +1801,7 @@ describe("filtering", () => { const [listNotInResponse] = await articleManager.listArticles({ where: { - ownedBy_not_in: ["id-87654321"] + createdBy_not_in: ["id-87654321"] } }); @@ -1819,7 +1821,7 @@ describe("filtering", () => { const [listNotInAllResponse] = await articleManager.listArticles({ where: { - ownedBy_not_in: ["id-87654321", "id-12345678"] + createdBy_not_in: ["id-87654321", "id-12345678"] } }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts index 8c2525a3b3c..9e7841951c0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts @@ -191,16 +191,17 @@ describe("latest entries", function () { id: expect.any(String), entryId: expect.any(String), createdOn: expect.any(String), - createdBy: expect.any(Object), - ownedBy: expect.any(Object), + modifiedOn: null, savedOn: expect.any(String), + createdBy: expect.any(Object), category: null, + lastPublishedOn: null, + firstPublishedOn: null, meta: { title, modelId: articleModel.modelId, version: 1, locked: false, - publishedOn: null, status: "draft", revisions: [ { @@ -239,9 +240,11 @@ describe("latest entries", function () { id: article.id, entryId: article.entryId, createdOn: article.createdOn, - createdBy: article.createdBy, - ownedBy: article.ownedBy, + modifiedOn: article.modifiedOn, savedOn: article.savedOn, + createdBy: article.createdBy, + firstPublishedOn: article.firstPublishedOn, + lastPublishedOn: article.lastPublishedOn, category: null, title, body, @@ -292,12 +295,12 @@ describe("latest entries", function () { createdOn: expect.any(String), createdBy: expect.any(Object), savedOn: expect.any(String), + lastPublishedOn: expect.stringMatching(/^20/), meta: { title: "Fruit 2", modelId: categoryModel.modelId, version: 2, locked: true, - publishedOn: expect.stringMatching(/^20/), status: "published", revisions: [ { @@ -340,9 +343,11 @@ describe("latest entries", function () { id: article.id, entryId: article.entryId, createdOn: article.createdOn, - createdBy: article.createdBy, - ownedBy: article.ownedBy, + modifiedOn: article.modifiedOn, savedOn: article.savedOn, + firstPublishedOn: article.firstPublishedOn, + lastPublishedOn: article.lastPublishedOn, + createdBy: article.createdBy, category: null, title, body, diff --git a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts index 544fd785b9b..dbc18751959 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts @@ -82,16 +82,18 @@ describe("predefined values", () => { data: { id: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, + lastPublishedOn: null, + firstPublishedOn: null, meta: { locked: false, modelId: "bug", - publishedOn: null, status: "draft", title: "A hard debuggable bug", version: 1 @@ -256,16 +258,18 @@ describe("predefined values", () => { data: { id: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, + lastPublishedOn: null, + firstPublishedOn: null, meta: { locked: false, modelId: "bug", - publishedOn: null, status: "draft", title: "Critical bug!", version: 1 @@ -306,16 +310,18 @@ describe("predefined values", () => { data: { id: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, + lastPublishedOn: null, + firstPublishedOn: null, meta: { locked: false, modelId: "bug", - publishedOn: null, status: "draft", title: "High bug value", version: 1 @@ -358,16 +364,18 @@ describe("predefined values", () => { data: { id: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, + lastPublishedOn: null, + firstPublishedOn: null, meta: { locked: false, modelId: "bug", - publishedOn: null, status: "draft", title: "High bug value", version: 1 @@ -435,16 +443,18 @@ describe("predefined values", () => { data: { id: expect.any(String), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, + lastPublishedOn: null, + firstPublishedOn: null, meta: { locked: false, modelId: "bug", - publishedOn: null, status: "draft", title: "High bug value", version: 1 diff --git a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts index aea51832d7f..0d15e59aa27 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts @@ -161,7 +161,7 @@ describe("refField", () => { }); const publishedReview = publishResponse.data.publishReview.data; - const { publishedOn } = publishedReview.meta; + const { firstPublishedOn, modifiedOn, lastPublishedOn } = publishedReview; const [manageGetResponse] = await manageGetReview({ revision: review.id @@ -182,10 +182,12 @@ describe("refField", () => { savedOn: publishedReview.savedOn, text: "review text", rating: 5, + lastPublishedOn, + firstPublishedOn, + modifiedOn, meta: { locked: true, modelId: "review", - publishedOn: publishedOn, revisions: [ { id: review.id, @@ -232,10 +234,12 @@ describe("refField", () => { displayName: "John Doe", type: "admin" }, + lastPublishedOn, + firstPublishedOn, + modifiedOn, meta: { locked: true, modelId: "review", - publishedOn: publishedOn, revisions: [ { id: review.id, diff --git a/packages/api-headless-cms/__tests__/contentAPI/references.test.ts b/packages/api-headless-cms/__tests__/contentAPI/references.test.ts index 073ff9ca5ba..5bdfc9b4fef 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/references.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/references.test.ts @@ -92,9 +92,11 @@ const extractReadArticle = (item: any, category?: any): Record => { id: item.id, entryId: item.entryId, createdOn: item.createdOn, + modifiedOn: expect.toBeDateString(), savedOn: item.savedOn, + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), createdBy: item.createdBy, - ownedBy: item.ownedBy, title: item.title, body: item.body, categories: category @@ -1064,11 +1066,10 @@ describe("entry references", () => { /** * Test is commented because we do not have access to the data loaders in the storage operations. */ - /** + /* it("should not produce multiple requests to the database when loading references", async () => { const group = await setupContentModelGroup(mainManager); await setupContentModels(mainManager, group, ["category", "article"]); - const categoryManager = useCategoryManageHandler(manageOpts); const articleManager = useArticleManageHandler({ ...manageOpts, @@ -1077,7 +1078,6 @@ describe("entry references", () => { const articleRead = useArticleReadHandler({ ...readOpts }); - const techCategory = await createCategoryItem({ manager: categoryManager, data: { @@ -1086,7 +1086,6 @@ describe("entry references", () => { }, publish: true }); - const totalCount = 10; for (let current = 1; current <= totalCount; current++) { await createArticleItem({ @@ -1102,11 +1101,9 @@ describe("entry references", () => { publish: true }); } - const [result] = await articleRead.listArticles({ limit: 1000 }); - expect(result.data.listArticles.data).toHaveLength(totalCount); expect(result).toMatchObject({ data: { diff --git a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts index dab338fc80b..e457e149ee1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts @@ -158,11 +158,6 @@ describe("Republish entries", () => { id: "admin", type: "admin", displayName: "Admin" - }, - ownedBy: { - id: "admin", - type: "admin", - displayName: "Admin" } }, input @@ -197,7 +192,9 @@ describe("Republish entries", () => { republishCategory: { data: { ...applePublished, - savedOn: expect.stringMatching(/^20/) + modifiedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + savedOn: expect.toBeDateString() }, error: null } @@ -213,7 +210,9 @@ describe("Republish entries", () => { republishCategory: { data: { ...bananaPublished, - savedOn: expect.stringMatching(/^20/) + modifiedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + savedOn: expect.toBeDateString() }, error: null } @@ -229,7 +228,9 @@ describe("Republish entries", () => { republishCategory: { data: { ...orangePublished, - savedOn: expect.stringMatching(/^20/) + modifiedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), + savedOn: expect.toBeDateString() }, error: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts index a7996f2ff25..2dbc70f4127 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts @@ -146,10 +146,10 @@ describe("MANAGE - resolvers - api key", () => { type: "api-key" }, savedOn: expect.stringMatching(/^20/), + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -196,10 +196,10 @@ describe("MANAGE - resolvers - api key", () => { type: "api-key" }, savedOn: category.savedOn, + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: category.id, @@ -248,10 +248,10 @@ describe("MANAGE - resolvers - api key", () => { type: "api-key" }, savedOn: expect.stringMatching(/^20/), + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -294,10 +294,10 @@ describe("MANAGE - resolvers - api key", () => { type: "api-key" }, savedOn: updatedCategory.savedOn, + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: updatedCategory.id, diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts index 5d754077e5d..98cfc5a3e1c 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts @@ -149,12 +149,12 @@ describe("MANAGE - Resolvers", () => { savedOn: expect.stringMatching(/^20/), title: "Hardware", slug: "hardware", + lastPublishedOn: null, meta: { title: "Hardware", modelId: "category", version: 1, locked: false, - publishedOn: null, status: "draft", revisions: [ { @@ -230,12 +230,12 @@ describe("MANAGE - Resolvers", () => { savedOn: expect.stringMatching(/^20/), title: "Hardware", slug: "hardware", + lastPublishedOn: null, meta: { title: "Hardware", modelId: "category", version: 1, locked: false, - publishedOn: null, status: "draft", revisions: [ { @@ -291,10 +291,10 @@ describe("MANAGE - Resolvers", () => { type: "admin" }, savedOn: publishedCategory.savedOn, + lastPublishedOn: expect.stringMatching(/^20/), meta: { locked: true, modelId: "category", - publishedOn: expect.stringMatching(/^20/), revisions: [ { id: expect.any(String), @@ -390,12 +390,12 @@ describe("MANAGE - Resolvers", () => { savedOn: expect.stringMatching(/^20/), title: "Hardware", slug: "hardware", + lastPublishedOn: null, meta: { title: "Hardware", modelId: "category", version: 1, locked: false, - publishedOn: null, status: "draft", revisions: [ { @@ -462,12 +462,12 @@ describe("MANAGE - Resolvers", () => { savedOn: expect.stringMatching(/^20/), title: "Hardware", slug: "hardware", + lastPublishedOn: null, meta: { title: "Hardware", modelId: "category", version: 1, locked: false, - publishedOn: null, status: "draft", revisions: [ { @@ -532,10 +532,10 @@ describe("MANAGE - Resolvers", () => { }, title: "Hardware", slug: "hardware", + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -612,10 +612,10 @@ describe("MANAGE - Resolvers", () => { savedOn: expect.stringMatching(/^20/), title: "New title", slug: "hardware-store", + lastPublishedOn: null, meta: { locked: false, modelId: "category", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -1064,7 +1064,10 @@ describe("MANAGE - Resolvers", () => { createdBy: expect.any(Object), meta: expect.any(Object), createdOn: expect.stringMatching(/^20/), + modifiedOn: null, savedOn: expect.stringMatching(/^20/), + firstPublishedOn: null, + lastPublishedOn: null, availableOn: "2020-12-25", color: "white", inStock: true, @@ -1203,11 +1206,18 @@ describe("MANAGE - Resolvers", () => { publishCategory: { data: { ...createdWebinyCategory, + modifiedBy: { + id: "id-12345678", + displayName: "John Doe", + type: "admin" + }, + modifiedOn: expect.any(String), + firstPublishedOn: expect.any(String), + lastPublishedOn: expect.any(String), meta: { ...createdWebinyCategory.meta, locked: true, status: "published", - publishedOn: expect.any(String), revisions: createdWebinyCategory.meta.revisions.map((rev: any) => { return { ...rev, @@ -1241,11 +1251,12 @@ describe("MANAGE - Resolvers", () => { createCategoryFrom: { data: { ...webiny, + modifiedOn: expect.stringMatching(/^20/), + lastPublishedOn: expect.stringMatching(/^20/), meta: { ...webiny.meta, locked: false, status: "draft", - publishedOn: expect.stringMatching(/^20/), version: i + 2, revisions: expect.any(Array) }, @@ -1272,11 +1283,12 @@ describe("MANAGE - Resolvers", () => { publishCategory: { data: { ...createdCategory, + modifiedOn: expect.any(String), + lastPublishedOn: expect.any(String), meta: { ...createdCategory.meta, locked: true, status: "published", - publishedOn: expect.any(String), revisions: expect.any(Array) }, savedOn: expect.any(String) @@ -1328,10 +1340,10 @@ describe("MANAGE - Resolvers", () => { entryId: webiny.entryId, createdOn: expect.stringMatching(/^20/), savedOn: expect.stringMatching(/^20/), + lastPublishedOn: expect.stringMatching(/^20/), meta: { locked: true, modelId: "category", - publishedOn: expect.stringMatching(/^20/), revisions: [ { id: `${webiny.entryId}#0006`, diff --git a/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts index 471e447ac8a..278c1596e7e 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts @@ -152,17 +152,20 @@ describe("richTextField", () => { data: { id: expect.any(String), entryId: expect.any(String), - createdOn: expect.stringMatching(/^20/), + createdOn: expect.toBeDateString(), + modifiedOn: null, + savedOn: expect.toBeDateString(), + firstPublishedOn: null, + lastPublishedOn: null, createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, - savedOn: expect.stringMatching(/^20/), title: "Potato", price: 100, image: "file.jpg", - availableOn: expect.stringMatching(/^20/), + availableOn: expect.toBeDateString(), color: "white", availableSizes: ["s", "m"], category: { @@ -177,7 +180,6 @@ describe("richTextField", () => { meta: { locked: false, modelId: "product", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -212,12 +214,15 @@ describe("richTextField", () => { data: { id: expect.any(String), entryId: expect.any(String), - createdOn: expect.stringMatching(/^20/), - savedOn: expect.stringMatching(/^20/), + createdOn: expect.toBeDateString(), + modifiedOn: expect.toBeDateString(), + savedOn: expect.toBeDateString(), + firstPublishedOn: expect.toBeDateString(), + lastPublishedOn: expect.toBeDateString(), title: "Potato", image: "file.jpg", price: 100, - availableOn: expect.stringMatching(/^20/), + availableOn: expect.toBeDateString(), color: "white", availableSizes: ["s", "m"], category: { @@ -267,17 +272,20 @@ describe("richTextField", () => { const expectedCreatedProduct = { id: expect.any(String), entryId: expect.any(String), - createdOn: expect.stringMatching(/^20/), + createdOn: expect.toBeDateString(), + modifiedOn: null, + savedOn: expect.toBeDateString(), + firstPublishedOn: null, + lastPublishedOn: null, createdBy: { id: "id-12345678", displayName: "John Doe", type: "admin" }, - savedOn: expect.stringMatching(/^20/), title: "Potato", price: 100, image: "file.jpg", - availableOn: expect.stringMatching(/^20/), + availableOn: expect.toBeDateString(), color: "white", availableSizes: ["s", "m"], category: { @@ -292,7 +300,6 @@ describe("richTextField", () => { meta: { locked: false, modelId: "product", - publishedOn: null, revisions: [ { id: expect.any(String), @@ -333,7 +340,8 @@ describe("richTextField", () => { updateProduct: { data: { ...expectedCreatedProduct, - richText: richTextMock + richText: richTextMock, + modifiedOn: expect.toBeDateString() }, error: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.manage.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.manage.ts index a8a24204789..3c23c7b336c 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.manage.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.manage.ts @@ -6,32 +6,26 @@ export default /* GraphQL */ ` id: ID! entryId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn''.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedBy.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! modifiedBy: CmsIdentity - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity meta: CategoryApiNameWhichIsABitDifferentThanModelIdMeta title: String @@ -44,7 +38,6 @@ export default /* GraphQL */ ` modelId: String version: Int locked: Boolean - publishedOn: DateTime status: String """ @@ -67,47 +60,26 @@ export default /* GraphQL */ ` # Set status of the entry. status: String - # Set a different date/time as the creation date/time of the entry. - createdOn: DateTime @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - - # Set a different date/time as the last modification date/time of the entry. - savedOn: DateTime @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - - # Set a different date/time as the publication date/time of the entry. - publishedOn: DateTime - @deprecated(reason: "Use 'revisionPublishedOn' or 'entryPublishedOn'.") - - # Set a different identity as the creator of the entry. + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + firstPublishedOn: DateTime + lastPublishedOn: DateTime createdBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - - # Set a different identity as the last editor of the entry. modifiedBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") - - # Set a different identity as the owner of the entry. - ownedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionOwnedBy' or 'entryOwnedBy'.") - + savedBy: CmsIdentityInput + firstPublishedBy: CmsIdentityInput + lastPublishedBy: CmsIdentityInput revisionCreatedOn: DateTime - revisionSavedOn: DateTime revisionModifiedOn: DateTime + revisionSavedOn: DateTime revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentityInput - revisionSavedBy: CmsIdentityInput revisionModifiedBy: CmsIdentityInput + revisionSavedBy: CmsIdentityInput revisionFirstPublishedBy: CmsIdentityInput revisionLastPublishedBy: CmsIdentityInput - entryCreatedOn: DateTime - entrySavedOn: DateTime - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentityInput - entrySavedBy: CmsIdentityInput - entryModifiedBy: CmsIdentityInput - entryFirstPublishedBy: CmsIdentityInput - entryLastPublishedBy: CmsIdentityInput wbyAco_location: WbyAcoLocationInput @@ -139,6 +111,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -146,21 +125,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -168,13 +166,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -182,6 +173,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -200,14 +198,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -216,61 +214,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -322,30 +265,26 @@ export default /* GraphQL */ ` enum CategoryApiNameWhichIsABitDifferentThanModelIdListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC title_ASC title_DESC slug_ASC @@ -413,7 +352,6 @@ export default /* GraphQL */ ` publishCategoryApiNameWhichIsABitDifferentThanModelId( revision: ID! - options: CmsPublishEntryOptionsInput ): CategoryApiNameWhichIsABitDifferentThanModelIdResponse republishCategoryApiNameWhichIsABitDifferentThanModelId( diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.read.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.read.ts index a1dbc0c50de..f90117705f3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.read.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/category.read.ts @@ -7,30 +7,26 @@ export default /* GraphQL */ ` entryId: String! modelId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedOn'.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! + modifiedBy: CmsIdentity + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity title: String slug: String @@ -59,6 +55,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -66,21 +69,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -88,13 +110,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -102,6 +117,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -120,14 +142,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -136,61 +158,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] title: String title_not: String @@ -217,30 +184,26 @@ export default /* GraphQL */ ` enum CategoryApiNameWhichIsABitDifferentThanModelIdListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC title_ASC title_DESC slug_ASC diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.manage.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.manage.ts index 0ea7f28b146..5e32beeeeaa 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.manage.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.manage.ts @@ -6,32 +6,26 @@ export default /* GraphQL */ ` id: ID! entryId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn''.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedBy.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! modifiedBy: CmsIdentity - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity meta: PageModelApiNameMeta content: [PageModelApiName_Content!] @@ -49,7 +43,6 @@ export default /* GraphQL */ ` modelId: String version: Int locked: Boolean - publishedOn: DateTime status: String """ @@ -361,47 +354,26 @@ export default /* GraphQL */ ` # Set status of the entry. status: String - # Set a different date/time as the creation date/time of the entry. - createdOn: DateTime @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - - # Set a different date/time as the last modification date/time of the entry. - savedOn: DateTime @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - - # Set a different date/time as the publication date/time of the entry. - publishedOn: DateTime - @deprecated(reason: "Use 'revisionPublishedOn' or 'entryPublishedOn'.") - - # Set a different identity as the creator of the entry. + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + firstPublishedOn: DateTime + lastPublishedOn: DateTime createdBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - - # Set a different identity as the last editor of the entry. modifiedBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") - - # Set a different identity as the owner of the entry. - ownedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionOwnedBy' or 'entryOwnedBy'.") - + savedBy: CmsIdentityInput + firstPublishedBy: CmsIdentityInput + lastPublishedBy: CmsIdentityInput revisionCreatedOn: DateTime - revisionSavedOn: DateTime revisionModifiedOn: DateTime + revisionSavedOn: DateTime revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentityInput - revisionSavedBy: CmsIdentityInput revisionModifiedBy: CmsIdentityInput + revisionSavedBy: CmsIdentityInput revisionFirstPublishedBy: CmsIdentityInput revisionLastPublishedBy: CmsIdentityInput - entryCreatedOn: DateTime - entrySavedOn: DateTime - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentityInput - entrySavedBy: CmsIdentityInput - entryModifiedBy: CmsIdentityInput - entryFirstPublishedBy: CmsIdentityInput - entryLastPublishedBy: CmsIdentityInput wbyAco_location: WbyAcoLocationInput @@ -436,6 +408,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -443,21 +422,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -465,13 +463,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -479,6 +470,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -497,14 +495,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -513,61 +511,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -601,30 +544,26 @@ export default /* GraphQL */ ` enum PageModelApiNameListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC } extend type Query { @@ -676,10 +615,7 @@ export default /* GraphQL */ ` deleteMultiplePagesModelApiName(entries: [ID!]!): CmsDeleteMultipleResponse! - publishPageModelApiName( - revision: ID! - options: CmsPublishEntryOptionsInput - ): PageModelApiNameResponse + publishPageModelApiName(revision: ID!): PageModelApiNameResponse republishPageModelApiName(revision: ID!): PageModelApiNameResponse diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.read.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.read.ts index 3a9904f7b30..358f4c4fa6b 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.read.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/page.read.ts @@ -7,30 +7,26 @@ export default /* GraphQL */ ` entryId: String! modelId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedOn'.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! + modifiedBy: CmsIdentity + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity content: [PageModelApiName_Content!] header: PageModelApiName_Header @@ -205,6 +201,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -212,21 +215,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -234,13 +256,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -248,6 +263,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -266,14 +288,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -282,61 +304,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] ghostObject: PageModelApiName_GhostObjectWhereInput AND: [PageModelApiNameListWhereInput!] OR: [PageModelApiNameListWhereInput!] @@ -345,30 +312,26 @@ export default /* GraphQL */ ` enum PageModelApiNameListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC } type PageModelApiNameResponse { diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.manage.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.manage.ts index dbe1769d851..1723f40791f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.manage.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.manage.ts @@ -6,32 +6,26 @@ export default /* GraphQL */ ` id: ID! entryId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn''.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedBy.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! modifiedBy: CmsIdentity - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity meta: ProductApiSingularMeta title: String @@ -54,7 +48,6 @@ export default /* GraphQL */ ` modelId: String version: Int locked: Boolean - publishedOn: DateTime status: String """ @@ -189,47 +182,26 @@ export default /* GraphQL */ ` # Set status of the entry. status: String - # Set a different date/time as the creation date/time of the entry. - createdOn: DateTime @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - - # Set a different date/time as the last modification date/time of the entry. - savedOn: DateTime @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - - # Set a different date/time as the publication date/time of the entry. - publishedOn: DateTime - @deprecated(reason: "Use 'revisionPublishedOn' or 'entryPublishedOn'.") - - # Set a different identity as the creator of the entry. + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + firstPublishedOn: DateTime + lastPublishedOn: DateTime createdBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - - # Set a different identity as the last editor of the entry. modifiedBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") - - # Set a different identity as the owner of the entry. - ownedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionOwnedBy' or 'entryOwnedBy'.") - + savedBy: CmsIdentityInput + firstPublishedBy: CmsIdentityInput + lastPublishedBy: CmsIdentityInput revisionCreatedOn: DateTime - revisionSavedOn: DateTime revisionModifiedOn: DateTime + revisionSavedOn: DateTime revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentityInput - revisionSavedBy: CmsIdentityInput revisionModifiedBy: CmsIdentityInput + revisionSavedBy: CmsIdentityInput revisionFirstPublishedBy: CmsIdentityInput revisionLastPublishedBy: CmsIdentityInput - entryCreatedOn: DateTime - entrySavedOn: DateTime - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentityInput - entrySavedBy: CmsIdentityInput - entryModifiedBy: CmsIdentityInput - entryFirstPublishedBy: CmsIdentityInput - entryLastPublishedBy: CmsIdentityInput wbyAco_location: WbyAcoLocationInput @@ -276,6 +248,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -283,21 +262,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -305,13 +303,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -319,6 +310,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -337,14 +335,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -353,61 +351,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -510,30 +453,26 @@ export default /* GraphQL */ ` enum ProductApiSingularListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC title_ASC title_DESC price_ASC @@ -599,10 +538,7 @@ export default /* GraphQL */ ` deleteMultipleProductPluralApiName(entries: [ID!]!): CmsDeleteMultipleResponse! - publishProductApiSingular( - revision: ID! - options: CmsPublishEntryOptionsInput - ): ProductApiSingularResponse + publishProductApiSingular(revision: ID!): ProductApiSingularResponse republishProductApiSingular(revision: ID!): ProductApiSingularResponse diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.read.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.read.ts index a48b3e60b1e..b39f80f49ed 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.read.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/product.read.ts @@ -7,30 +7,26 @@ export default /* GraphQL */ ` entryId: String! modelId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedOn'.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! + modifiedBy: CmsIdentity + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity title: String category(populate: Boolean = true): CategoryApiNameWhichIsABitDifferentThanModelId @@ -165,6 +161,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -172,21 +175,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -194,13 +216,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -208,6 +223,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -226,14 +248,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -242,61 +264,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] title: String title_not: String @@ -374,30 +341,26 @@ export default /* GraphQL */ ` enum ProductApiSingularListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC title_ASC title_DESC price_ASC diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.manage.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.manage.ts index 41de02e229c..a608d0bdff7 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.manage.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.manage.ts @@ -6,32 +6,26 @@ export default /* GraphQL */ ` id: ID! entryId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn''.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedBy.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! modifiedBy: CmsIdentity - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity meta: ReviewApiModelMeta text: String @@ -46,7 +40,6 @@ export default /* GraphQL */ ` modelId: String version: Int locked: Boolean - publishedOn: DateTime status: String """ @@ -69,47 +62,26 @@ export default /* GraphQL */ ` # Set status of the entry. status: String - # Set a different date/time as the creation date/time of the entry. - createdOn: DateTime @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - - # Set a different date/time as the last modification date/time of the entry. - savedOn: DateTime @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - - # Set a different date/time as the publication date/time of the entry. - publishedOn: DateTime - @deprecated(reason: "Use 'revisionPublishedOn' or 'entryPublishedOn'.") - - # Set a different identity as the creator of the entry. + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + firstPublishedOn: DateTime + lastPublishedOn: DateTime createdBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - - # Set a different identity as the last editor of the entry. modifiedBy: CmsIdentityInput - @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") - - # Set a different identity as the owner of the entry. - ownedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionOwnedBy' or 'entryOwnedBy'.") - + savedBy: CmsIdentityInput + firstPublishedBy: CmsIdentityInput + lastPublishedBy: CmsIdentityInput revisionCreatedOn: DateTime - revisionSavedOn: DateTime revisionModifiedOn: DateTime + revisionSavedOn: DateTime revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentityInput - revisionSavedBy: CmsIdentityInput revisionModifiedBy: CmsIdentityInput + revisionSavedBy: CmsIdentityInput revisionFirstPublishedBy: CmsIdentityInput revisionLastPublishedBy: CmsIdentityInput - entryCreatedOn: DateTime - entrySavedOn: DateTime - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentityInput - entrySavedBy: CmsIdentityInput - entryModifiedBy: CmsIdentityInput - entryFirstPublishedBy: CmsIdentityInput - entryLastPublishedBy: CmsIdentityInput wbyAco_location: WbyAcoLocationInput @@ -143,6 +115,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -150,21 +129,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -172,13 +170,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -186,6 +177,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -204,14 +202,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -220,61 +218,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] status: String status_not: String status_in: [String!] @@ -334,30 +277,26 @@ export default /* GraphQL */ ` enum ReviewApiModelListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC text_ASC text_DESC rating_ASC @@ -413,10 +352,7 @@ export default /* GraphQL */ ` deleteMultipleReviewsApiModel(entries: [ID!]!): CmsDeleteMultipleResponse! - publishReviewApiModel( - revision: ID! - options: CmsPublishEntryOptionsInput - ): ReviewApiModelResponse + publishReviewApiModel(revision: ID!): ReviewApiModelResponse republishReviewApiModel(revision: ID!): ReviewApiModelResponse diff --git a/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.read.ts b/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.read.ts index b3538f3b3d5..d194fb3c122 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.read.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/snapshots/review.read.ts @@ -7,30 +7,26 @@ export default /* GraphQL */ ` entryId: String! modelId: String! - createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedOn'.") + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + firstPublishedOn: DateTime + lastPublishedOn: DateTime + createdBy: CmsIdentity! + modifiedBy: CmsIdentity + savedBy: CmsIdentity! + firstPublishedBy: CmsIdentity + lastPublishedBy: CmsIdentity revisionCreatedOn: DateTime! - revisionSavedOn: DateTime! revisionModifiedOn: DateTime + revisionSavedOn: DateTime! revisionFirstPublishedOn: DateTime revisionLastPublishedOn: DateTime revisionCreatedBy: CmsIdentity! - revisionSavedBy: CmsIdentity! revisionModifiedBy: CmsIdentity + revisionSavedBy: CmsIdentity! revisionFirstPublishedBy: CmsIdentity revisionLastPublishedBy: CmsIdentity - entryCreatedOn: DateTime! - entrySavedOn: DateTime! - entryModifiedOn: DateTime - entryFirstPublishedOn: DateTime - entryLastPublishedOn: DateTime - entryCreatedBy: CmsIdentity! - entrySavedBy: CmsIdentity! - entryModifiedBy: CmsIdentity - entryFirstPublishedBy: CmsIdentity - entryLastPublishedBy: CmsIdentity text: String product(populate: Boolean = true): ProductApiSingular @@ -61,6 +57,13 @@ export default /* GraphQL */ ` createdOn_lte: DateTime createdOn_between: [DateTime!] createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] savedOn: DateTime savedOn_gt: DateTime savedOn_gte: DateTime @@ -68,21 +71,40 @@ export default /* GraphQL */ ` savedOn_lte: DateTime savedOn_between: [DateTime!] savedOn_not_between: [DateTime!] - publishedOn: DateTime - publishedOn_gt: DateTime - publishedOn_gte: DateTime - publishedOn_lt: DateTime - publishedOn_lte: DateTime - publishedOn_between: [DateTime!] - publishedOn_not_between: [DateTime!] - createdBy: String - createdBy_not: String - createdBy_in: [String!] - createdBy_not_in: [String!] - ownedBy: String - ownedBy_not: String - ownedBy_in: [String!] - ownedBy_not_in: [String!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] revisionCreatedOn: DateTime revisionCreatedOn_gt: DateTime revisionCreatedOn_gte: DateTime @@ -90,13 +112,6 @@ export default /* GraphQL */ ` revisionCreatedOn_lte: DateTime revisionCreatedOn_between: [DateTime!] revisionCreatedOn_not_between: [DateTime!] - revisionSavedOn: DateTime - revisionSavedOn_gt: DateTime - revisionSavedOn_gte: DateTime - revisionSavedOn_lt: DateTime - revisionSavedOn_lte: DateTime - revisionSavedOn_between: [DateTime!] - revisionSavedOn_not_between: [DateTime!] revisionModifiedOn: DateTime revisionModifiedOn_gt: DateTime revisionModifiedOn_gte: DateTime @@ -104,6 +119,13 @@ export default /* GraphQL */ ` revisionModifiedOn_lte: DateTime revisionModifiedOn_between: [DateTime!] revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] revisionFirstPublishedOn: DateTime revisionFirstPublishedOn_gt: DateTime revisionFirstPublishedOn_gte: DateTime @@ -122,14 +144,14 @@ export default /* GraphQL */ ` revisionCreatedBy_not: ID revisionCreatedBy_in: [ID!] revisionCreatedBy_not_in: [ID!] - revisionSavedBy: ID - revisionSavedBy_not: ID - revisionSavedBy_in: [ID!] - revisionSavedBy_not_in: [ID!] revisionModifiedBy: ID revisionModifiedBy_not: ID revisionModifiedBy_in: [ID!] revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] revisionFirstPublishedBy: ID revisionFirstPublishedBy_not: ID revisionFirstPublishedBy_in: [ID!] @@ -138,61 +160,6 @@ export default /* GraphQL */ ` revisionLastPublishedBy_not: ID revisionLastPublishedBy_in: [ID!] revisionLastPublishedBy_not_in: [ID!] - entryCreatedOn: DateTime - entryCreatedOn_gt: DateTime - entryCreatedOn_gte: DateTime - entryCreatedOn_lt: DateTime - entryCreatedOn_lte: DateTime - entryCreatedOn_between: [DateTime!] - entryCreatedOn_not_between: [DateTime!] - entrySavedOn: DateTime - entrySavedOn_gt: DateTime - entrySavedOn_gte: DateTime - entrySavedOn_lt: DateTime - entrySavedOn_lte: DateTime - entrySavedOn_between: [DateTime!] - entrySavedOn_not_between: [DateTime!] - entryModifiedOn: DateTime - entryModifiedOn_gt: DateTime - entryModifiedOn_gte: DateTime - entryModifiedOn_lt: DateTime - entryModifiedOn_lte: DateTime - entryModifiedOn_between: [DateTime!] - entryModifiedOn_not_between: [DateTime!] - entryFirstPublishedOn: DateTime - entryFirstPublishedOn_gt: DateTime - entryFirstPublishedOn_gte: DateTime - entryFirstPublishedOn_lt: DateTime - entryFirstPublishedOn_lte: DateTime - entryFirstPublishedOn_between: [DateTime!] - entryFirstPublishedOn_not_between: [DateTime!] - entryLastPublishedOn: DateTime - entryLastPublishedOn_gt: DateTime - entryLastPublishedOn_gte: DateTime - entryLastPublishedOn_lt: DateTime - entryLastPublishedOn_lte: DateTime - entryLastPublishedOn_between: [DateTime!] - entryLastPublishedOn_not_between: [DateTime!] - entryCreatedBy: ID - entryCreatedBy_not: ID - entryCreatedBy_in: [ID!] - entryCreatedBy_not_in: [ID!] - entrySavedBy: ID - entrySavedBy_not: ID - entrySavedBy_in: [ID!] - entrySavedBy_not_in: [ID!] - entryModifiedBy: ID - entryModifiedBy_not: ID - entryModifiedBy_in: [ID!] - entryModifiedBy_not_in: [ID!] - entryFirstPublishedBy: ID - entryFirstPublishedBy_not: ID - entryFirstPublishedBy_in: [ID!] - entryFirstPublishedBy_not_in: [ID!] - entryLastPublishedBy: ID - entryLastPublishedBy_not: ID - entryLastPublishedBy_in: [ID!] - entryLastPublishedBy_not_in: [ID!] text: String text_not: String @@ -227,30 +194,26 @@ export default /* GraphQL */ ` enum ReviewApiModelListSorter { id_ASC id_DESC - savedOn_ASC - savedOn_DESC createdOn_ASC createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC revisionCreatedOn_ASC revisionCreatedOn_DESC - revisionSavedOn_ASC - revisionSavedOn_DESC revisionModifiedOn_ASC revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC revisionFirstPublishedOn_ASC revisionFirstPublishedOn_DESC revisionLastPublishedOn_ASC revisionLastPublishedOn_DESC - entryCreatedOn_ASC - entryCreatedOn_DESC - entrySavedOn_ASC - entrySavedOn_DESC - entryModifiedOn_ASC - entryModifiedOn_DESC - entryFirstPublishedOn_ASC - entryFirstPublishedOn_DESC - entryLastPublishedOn_ASC - entryLastPublishedOn_DESC text_ASC text_DESC rating_ASC diff --git a/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts b/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts index e3e803a036f..c0b60ccef72 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts @@ -98,7 +98,10 @@ describe("storage transform for complex entries", () => { id: expect.any(String), entryId: expect.any(String), createdOn: expect.any(String), + modifiedOn: null, savedOn: expect.any(String), + firstPublishedOn: null, + lastPublishedOn: null, createdBy: expect.any(Object), ...product, category: categoryAsRef(), diff --git a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts index 7402e3883f5..c77d66da633 100644 --- a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts +++ b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts @@ -901,19 +901,20 @@ const createBaseEntry = (values: Record): CmsEntry => { return { id: "someEntryId#0001", entryId: "someEntryId", - createdBy: { + createdOn: "2022-09-01T12:00:00Z", + savedOn: "2022-09-01T12:00:00Z", + firstPublishedOn: null, + lastPublishedOn: null, + revisionCreatedBy: { id: "id", type: "admin", displayName: "Admin User" }, - ownedBy: { + createdBy: { id: "id", type: "admin", displayName: "Admin User" }, - createdOn: "2022-09-01T12:00:00Z", - savedOn: "2022-09-01T12:00:00Z", - publishedOn: undefined, modelId: "test", locale: "en-US", tenant: "root", diff --git a/packages/api-headless-cms/__tests__/helpers/renderSortEnum.test.ts b/packages/api-headless-cms/__tests__/helpers/renderSortEnum.test.ts index 8677ed55cc1..0c119c0b801 100644 --- a/packages/api-headless-cms/__tests__/helpers/renderSortEnum.test.ts +++ b/packages/api-headless-cms/__tests__/helpers/renderSortEnum.test.ts @@ -32,30 +32,26 @@ describe("Render GraphQL sort enum", () => { [ "id_ASC", "id_DESC", - "savedOn_ASC", - "savedOn_DESC", "createdOn_ASC", "createdOn_DESC", + "modifiedOn_ASC", + "modifiedOn_DESC", + "savedOn_ASC", + "savedOn_DESC", + "firstPublishedOn_ASC", + "firstPublishedOn_DESC", + "lastPublishedOn_ASC", + "lastPublishedOn_DESC", "revisionCreatedOn_ASC", "revisionCreatedOn_DESC", - "revisionSavedOn_ASC", - "revisionSavedOn_DESC", "revisionModifiedOn_ASC", "revisionModifiedOn_DESC", + "revisionSavedOn_ASC", + "revisionSavedOn_DESC", "revisionFirstPublishedOn_ASC", "revisionFirstPublishedOn_DESC", "revisionLastPublishedOn_ASC", "revisionLastPublishedOn_DESC", - "entryCreatedOn_ASC", - "entryCreatedOn_DESC", - "entrySavedOn_ASC", - "entrySavedOn_DESC", - "entryModifiedOn_ASC", - "entryModifiedOn_DESC", - "entryFirstPublishedOn_ASC", - "entryFirstPublishedOn_DESC", - "entryLastPublishedOn_ASC", - "entryLastPublishedOn_DESC", "title_ASC", "title_DESC", "price_ASC", @@ -90,30 +86,26 @@ describe("Render GraphQL sort enum", () => { [ "id_ASC", "id_DESC", - "savedOn_ASC", - "savedOn_DESC", "createdOn_ASC", "createdOn_DESC", + "modifiedOn_ASC", + "modifiedOn_DESC", + "savedOn_ASC", + "savedOn_DESC", + "firstPublishedOn_ASC", + "firstPublishedOn_DESC", + "lastPublishedOn_ASC", + "lastPublishedOn_DESC", "revisionCreatedOn_ASC", "revisionCreatedOn_DESC", - "revisionSavedOn_ASC", - "revisionSavedOn_DESC", "revisionModifiedOn_ASC", "revisionModifiedOn_DESC", + "revisionSavedOn_ASC", + "revisionSavedOn_DESC", "revisionFirstPublishedOn_ASC", "revisionFirstPublishedOn_DESC", "revisionLastPublishedOn_ASC", "revisionLastPublishedOn_DESC", - "entryCreatedOn_ASC", - "entryCreatedOn_DESC", - "entrySavedOn_ASC", - "entrySavedOn_DESC", - "entryModifiedOn_ASC", - "entryModifiedOn_DESC", - "entryFirstPublishedOn_ASC", - "entryFirstPublishedOn_DESC", - "entryLastPublishedOn_ASC", - "entryLastPublishedOn_DESC", "title_ASC", "title_DESC", "price_ASC", diff --git a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts index 34750baef19..dcccb0dcd7d 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts @@ -98,12 +98,12 @@ export const createPersonModel = (): CmsModel => { }; }; -const createdBy: CmsIdentity = { +const revisionCreatedBy: CmsIdentity = { id: "admin", type: "admin", displayName: "admin" }; -const ownedBy: CmsIdentity = { +const createdBy: CmsIdentity = { id: "admin", type: "admin", displayName: "admin" @@ -141,8 +141,8 @@ export const createPersonEntries = async ( id, entryId, version: 1, + revisionCreatedBy, createdBy, - ownedBy, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), modelId: personModel.modelId, @@ -177,8 +177,8 @@ export const createPersonEntries = async ( id, entryId, version: nextVersion, + revisionCreatedBy, createdBy, - ownedBy, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), modelId: personModel.modelId, diff --git a/packages/api-headless-cms/__tests__/testHelpers/useArticleManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useArticleManageHandler.ts index d17e55ffd16..d5f044702d7 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useArticleManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useArticleManageHandler.ts @@ -6,23 +6,20 @@ const fields = ` id entryId createdOn + modifiedOn savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - ownedBy { - id - displayName - type - } meta { title modelId version locked - publishedOn status revisions { id diff --git a/packages/api-headless-cms/__tests__/testHelpers/useArticleReadHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useArticleReadHandler.ts index 89009738490..37129840f8c 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useArticleReadHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useArticleReadHandler.ts @@ -6,17 +6,15 @@ const fields = ` id entryId createdOn + modifiedOn savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - ownedBy { - id - displayName - type - } title body categories { diff --git a/packages/api-headless-cms/__tests__/testHelpers/useAuthorManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useAuthorManageHandler.ts index 11700f4918b..115338af9f9 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useAuthorManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useAuthorManageHandler.ts @@ -6,18 +6,20 @@ const authorFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status revisions { id diff --git a/packages/api-headless-cms/__tests__/testHelpers/useBugManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useBugManageHandler.ts index 1ab509bd3d8..a1fb093b753 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useBugManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useBugManageHandler.ts @@ -5,18 +5,20 @@ import { getCmsModel } from "~tests/contentAPI/mocks/contentModels"; const bugFields = ` id createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status } # user defined fields diff --git a/packages/api-headless-cms/__tests__/testHelpers/useCategoryManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useCategoryManageHandler.ts index 503f7e0a938..97b63350507 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useCategoryManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useCategoryManageHandler.ts @@ -14,17 +14,18 @@ const categoryFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy ${identityFields} modifiedBy ${identityFields} - ownedBy ${identityFields} - savedOn - + savedBy ${identityFields} meta { title modelId version locked - publishedOn status revisions { @@ -184,8 +185,8 @@ const deleteCategoriesMutation = (model: CmsModel) => { const publishCategoryMutation = (model: CmsModel) => { return /* GraphQL */ ` - mutation PublishCategory($revision: ID!, $options: CmsPublishEntryOptionsInput) { - publishCategory: publish${model.singularApiName}(revision: $revision, options: $options) { + mutation PublishCategory($revision: ID!) { + publishCategory: publish${model.singularApiName}(revision: $revision) { data { ${categoryFields} } diff --git a/packages/api-headless-cms/__tests__/testHelpers/useFruitManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useFruitManageHandler.ts index 5555636bb22..1fc47bda1e2 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useFruitManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useFruitManageHandler.ts @@ -6,18 +6,20 @@ const fruitFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status revisions { id diff --git a/packages/api-headless-cms/__tests__/testHelpers/useFruitReadHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useFruitReadHandler.ts index bb6dc0af840..af8e5dfe41e 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useFruitReadHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useFruitReadHandler.ts @@ -6,12 +6,15 @@ const fruitFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn # user defined fields name numbers diff --git a/packages/api-headless-cms/__tests__/testHelpers/useProductManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useProductManageHandler.ts index f55e6d8a42a..cdd7c70ec63 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useProductManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useProductManageHandler.ts @@ -6,18 +6,20 @@ const productFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status revisions { id diff --git a/packages/api-headless-cms/__tests__/testHelpers/useProductReadHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useProductReadHandler.ts index d62569c50c5..7f152c3199c 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useProductReadHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useProductReadHandler.ts @@ -6,7 +6,10 @@ const productFields = ` id entryId createdOn + modifiedOn savedOn + lastPublishedOn + firstPublishedOn # user defined fields title category { diff --git a/packages/api-headless-cms/__tests__/testHelpers/useReviewManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useReviewManageHandler.ts index 136540f6bfc..19a00b1a7e1 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useReviewManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useReviewManageHandler.ts @@ -6,18 +6,20 @@ const reviewFields = ` id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status revisions { id diff --git a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts index bb77299a99e..e384e6ac69c 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts @@ -18,38 +18,31 @@ export const fields = /* GraphQL */ `{ id entryId createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy ${identityFields} modifiedBy ${identityFields} - ownedBy ${identityFields} - savedOn - + savedBy ${identityFields} + firstPublishedBy ${identityFields} + lastPublishedBy ${identityFields} revisionCreatedOn - revisionSavedOn revisionModifiedOn + revisionSavedOn revisionFirstPublishedOn revisionLastPublishedOn revisionCreatedBy ${identityFields} - revisionSavedBy ${identityFields} revisionModifiedBy ${identityFields} + revisionSavedBy ${identityFields} revisionFirstPublishedBy ${identityFields} revisionLastPublishedBy ${identityFields} - entryCreatedOn - entrySavedOn - entryModifiedOn - entryCreatedBy ${identityFields} - entrySavedBy ${identityFields} - entryModifiedBy ${identityFields} - entryFirstPublishedBy ${identityFields} - entryLastPublishedBy ${identityFields} - entryFirstPublishedOn - entryLastPublishedOn meta { title modelId version locked - publishedOn status revisions { @@ -167,8 +160,8 @@ export const DELETE_TEST_ENTRIES = /* GraphQL */ ` `; export const PUBLISH_TEST_ENTRY = /* GraphQL */ ` - mutation PublishTestEntry($revision: ID!, $options: CmsPublishEntryOptionsInput) { - publishTestEntry: publishTestEntry(revision: $revision, options: $options) { + mutation PublishTestEntry($revision: ID!) { + publishTestEntry: publishTestEntry(revision: $revision) { data ${fields} error ${errorFields} } diff --git a/packages/api-headless-cms/__tests__/testHelpers/useWrapManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/useWrapManageHandler.ts index 154757b1be6..48e2ee0ff80 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useWrapManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useWrapManageHandler.ts @@ -5,18 +5,20 @@ import { getCmsModel } from "~tests/contentAPI/mocks/contentModels"; const fields = ` id createdOn + modifiedOn + savedOn + firstPublishedOn + lastPublishedOn createdBy { id displayName type } - savedOn meta { title modelId version locked - publishedOn status } # user defined fields @@ -39,12 +41,12 @@ const errorFields = ` const createWrapMutation = (model: CmsModel) => { return /* GraphQL */ ` mutation CreateWrap($data: ${model.singularApiName}Input!) { - createWrap: create${model.singularApiName}(data: $data) { - data { - ${fields} - } - ${errorFields} - } + createWrap: create${model.singularApiName}(data: $data) { + data { + ${fields} + } + ${errorFields} + } } `; }; @@ -53,11 +55,11 @@ const publishWrapMutation = (model: CmsModel) => { return /* GraphQL */ ` mutation PublishWrap($revision: ID!) { publishWrap: publish${model.singularApiName}(revision: $revision) { - data { - ${fields} - } - ${errorFields} + data { + ${fields} } + ${errorFields} + } } `; }; diff --git a/packages/api-headless-cms/src/constants.ts b/packages/api-headless-cms/src/constants.ts index b9a1e4ef051..1a6001257be 100644 --- a/packages/api-headless-cms/src/constants.ts +++ b/packages/api-headless-cms/src/constants.ts @@ -4,29 +4,29 @@ export const ROOT_FOLDER = "root"; // Content entries - xOn and xBy meta fields. export const ENTRY_META_FIELDS = [ + // Entry-level meta fields. + "createdOn", + "modifiedOn", + "savedOn", + "firstPublishedOn", + "lastPublishedOn", + "createdBy", + "modifiedBy", + "savedBy", + "firstPublishedBy", + "lastPublishedBy", + // Revision-level meta fields. "revisionCreatedOn", - "revisionSavedOn", "revisionModifiedOn", + "revisionSavedOn", "revisionFirstPublishedOn", "revisionLastPublishedOn", "revisionCreatedBy", - "revisionSavedBy", "revisionModifiedBy", + "revisionSavedBy", "revisionFirstPublishedBy", - "revisionLastPublishedBy", - - // Entry-level meta fields. - "entryCreatedOn", - "entrySavedOn", - "entryModifiedOn", - "entryFirstPublishedOn", - "entryLastPublishedOn", - "entryCreatedBy", - "entrySavedBy", - "entryModifiedBy", - "entryFirstPublishedBy", - "entryLastPublishedBy" + "revisionLastPublishedBy" ] as const; export type EntryMetaFieldName = (typeof ENTRY_META_FIELDS)[number]; @@ -44,21 +44,21 @@ export interface RecordWithEntryMetaFields { revisionLastPublishedBy: CmsIdentity | null; // Entry-level meta fields. - entryCreatedOn: string; - entrySavedOn: string; - entryModifiedOn: string | null; - entryFirstPublishedOn: string | null; - entryLastPublishedOn: string | null; - entryCreatedBy: CmsIdentity; - entrySavedBy: CmsIdentity; - entryModifiedBy: CmsIdentity | null; - entryFirstPublishedBy: CmsIdentity | null; - entryLastPublishedBy: CmsIdentity | null; + createdOn: string; + savedOn: string; + modifiedOn: string | null; + firstPublishedOn: string | null; + lastPublishedOn: string | null; + createdBy: CmsIdentity; + savedBy: CmsIdentity; + modifiedBy: CmsIdentity | null; + firstPublishedBy: CmsIdentity | null; + lastPublishedBy: CmsIdentity | null; } export const pickEntryMetaFields = ( object: Partial, - filter?: (fieldName: string) => boolean + filter?: (fieldName: EntryMetaFieldName | string) => boolean ) => { const pickedEntryMetaFields: Partial = {}; for (const entryMetaFieldName of ENTRY_META_FIELDS) { @@ -77,7 +77,8 @@ export const pickEntryMetaFields = ( export const isNullableEntryMetaField = (fieldName: EntryMetaFieldName) => { // Only modifiedX and publishedX fields are nullable. - return fieldName.includes("Modified") || fieldName.includes("Published"); + const lcFieldName = fieldName.toLowerCase(); + return lcFieldName.includes("modified") || lcFieldName.includes("published"); }; export const isDateTimeEntryMetaField = (fieldName: EntryMetaFieldName) => { @@ -89,3 +90,17 @@ export const isIdentityEntryMetaField = (fieldName: EntryMetaFieldName) => { // Only field ending with "On" are date/time fields. return fieldName.endsWith("By"); }; + +export const isRevisionEntryMetaField = (fieldName: string) => { + return ( + ENTRY_META_FIELDS.includes(fieldName as EntryMetaFieldName) && + fieldName.startsWith("revision") + ); +}; + +export const isEntryLevelEntryMetaField = (fieldName: string) => { + return ( + ENTRY_META_FIELDS.includes(fieldName as EntryMetaFieldName) && + !fieldName.startsWith("revision") + ); +}; diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index ccc85826dbc..93fc68a6482 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -61,7 +61,7 @@ import { filterAsync } from "~/utils/filterAsync"; import { EntriesPermissions } from "~/utils/permissions/EntriesPermissions"; import { ModelsPermissions } from "~/utils/permissions/ModelsPermissions"; import { NotAuthorizedError } from "@webiny/api-security"; -import { pickEntryMetaFields } from "~/constants"; +import { isEntryLevelEntryMetaField, pickEntryMetaFields } from "~/constants"; import { createEntryData, createEntryRevisionFromData, @@ -402,7 +402,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm * Or if searching for the owner set that value - in the case that user can see other entries than their own. */ if (await entriesPermissions.canAccessOnlyOwnRecords()) { - where.entryCreatedBy = getSecurityIdentity().id; + where.createdBy = getSecurityIdentity().id; } /** @@ -578,7 +578,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm */ const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); - await entriesPermissions.ensure({ owns: originalEntry.entryCreatedBy }); + await entriesPermissions.ensure({ owns: originalEntry.createdBy }); const { entry, input } = await createEntryRevisionFromData({ sourceId, @@ -958,9 +958,10 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm * fields. The values are taken from the latest revision we're about to delete. The update of the * new latest revision is performed within storage operations. */ - const pickedEntryLevelMetaFields = pickEntryMetaFields(entryToDelete, field => { - return field.startsWith("entry"); - }); + const pickedEntryLevelMetaFields = pickEntryMetaFields( + entryToDelete, + isEntryLevelEntryMetaField + ); updatedEntryToSetAsLatest = { ...entryToSetAsLatest, @@ -1046,7 +1047,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm */ const items = ( await filterAsync(entries, async entry => { - return entriesPermissions.ensure({ owns: entry.entryCreatedBy }, { throw: false }); + return entriesPermissions.ensure({ owns: entry.createdBy }, { throw: false }); }) ).map(entry => entry.id); @@ -1118,7 +1119,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); } - await entriesPermissions.ensure({ owns: storageEntry.entryCreatedBy }); + await entriesPermissions.ensure({ owns: storageEntry.createdBy }); const entry = await entryFromStorageTransform(context, model, storageEntry); @@ -1127,7 +1128,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm entry }); }; - const publishEntry: CmsEntryContext["publishEntry"] = async (model, id, options) => { + const publishEntry: CmsEntryContext["publishEntry"] = async (model, id) => { await entriesPermissions.ensure({ pw: "p" }); await modelsPermissions.ensureCanAccessModel({ model @@ -1162,7 +1163,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const { entry } = await createPublishEntryData({ context, model, - options, originalEntry, latestEntry, getIdentity: getSecurityIdentity @@ -1300,7 +1300,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm * Or if searching for the owner set that value - in the case that user can see other entries than their own. */ if (await entriesPermissions.canAccessOnlyOwnRecords()) { - where.entryCreatedBy = getSecurityIdentity().id; + where.createdBy = getSecurityIdentity().id; } /** @@ -1562,9 +1562,9 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm } ); }, - async publishEntry(model, id, options) { + async publishEntry(model, id) { return context.benchmark.measure("headlessCms.crud.entries.publishEntry", async () => { - return publishEntry(model, id, options); + return publishEntry(model, id); }); }, async unpublishEntry(model, id) { diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts index 64b072b76a1..2b75fe7dd48 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts @@ -104,21 +104,18 @@ export const createEntryData = async ({ let entryLevelPublishingMetaFields: Pick< CmsEntry, - | "entryFirstPublishedOn" - | "entryLastPublishedOn" - | "entryFirstPublishedBy" - | "entryLastPublishedBy" + "firstPublishedOn" | "lastPublishedOn" | "firstPublishedBy" | "lastPublishedBy" > = { - entryFirstPublishedOn: null, - entryLastPublishedOn: null, - entryFirstPublishedBy: null, - entryLastPublishedBy: null + firstPublishedOn: null, + lastPublishedOn: null, + firstPublishedBy: null, + lastPublishedBy: null }; if (status === STATUS_PUBLISHED) { revisionLevelPublishingMetaFields = { revisionFirstPublishedOn: getDate(rawInput.revisionFirstPublishedOn, currentDateTime), - revisionLastPublishedOn: getDate(rawInput.revisionFirstPublishedOn, currentDateTime), + revisionLastPublishedOn: getDate(rawInput.revisionLastPublishedOn, currentDateTime), revisionFirstPublishedBy: getIdentity( rawInput.revisionFirstPublishedBy, currentIdentity @@ -127,10 +124,10 @@ export const createEntryData = async ({ }; entryLevelPublishingMetaFields = { - entryFirstPublishedOn: getDate(rawInput.entryFirstPublishedOn, currentDateTime), - entryLastPublishedOn: getDate(rawInput.entryFirstPublishedOn, currentDateTime), - entryFirstPublishedBy: getIdentity(rawInput.entryFirstPublishedBy, currentIdentity), - entryLastPublishedBy: getIdentity(rawInput.entryLastPublishedBy, currentIdentity) + firstPublishedOn: getDate(rawInput.firstPublishedOn, currentDateTime), + lastPublishedOn: getDate(rawInput.lastPublishedOn, currentDateTime), + firstPublishedBy: getIdentity(rawInput.firstPublishedBy, currentIdentity), + lastPublishedBy: getIdentity(rawInput.lastPublishedBy, currentIdentity) }; } @@ -143,43 +140,27 @@ export const createEntryData = async ({ locale: locale.code, /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. + * Entry-level meta fields. 👇 */ createdOn: getDate(rawInput.createdOn, currentDateTime), + modifiedOn: getDate(rawInput.modifiedOn, null), savedOn: getDate(rawInput.savedOn, currentDateTime), - publishedOn: getDate(rawInput.publishedOn), createdBy: getIdentity(rawInput.createdBy, currentIdentity), - ownedBy: getIdentity(rawInput.ownedBy, currentIdentity), modifiedBy: getIdentity(rawInput.modifiedBy, null), - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ + savedBy: getIdentity(rawInput.savedBy, currentIdentity), + ...entryLevelPublishingMetaFields, /** * Revision-level meta fields. 👇 */ revisionCreatedOn: getDate(rawInput.revisionCreatedOn, currentDateTime), - revisionSavedOn: getDate(rawInput.revisionSavedOn, currentDateTime), revisionModifiedOn: getDate(rawInput.revisionModifiedOn, null), + revisionSavedOn: getDate(rawInput.revisionSavedOn, currentDateTime), revisionCreatedBy: getIdentity(rawInput.revisionCreatedBy, currentIdentity), - revisionSavedBy: getIdentity(rawInput.revisionSavedBy, currentIdentity), revisionModifiedBy: getIdentity(rawInput.revisionModifiedBy, null), + revisionSavedBy: getIdentity(rawInput.revisionSavedBy, currentIdentity), ...revisionLevelPublishingMetaFields, - /** - * Entry-level meta fields. 👇 - */ - entryCreatedOn: getDate(rawInput.entryCreatedOn, currentDateTime), - entrySavedOn: getDate(rawInput.entrySavedOn, currentDateTime), - entryModifiedOn: getDate(rawInput.entryModifiedOn, null), - entryCreatedBy: getIdentity(rawInput.entryCreatedBy, currentIdentity), - entrySavedBy: getIdentity(rawInput.entrySavedBy, currentIdentity), - entryModifiedBy: getIdentity(rawInput.entryModifiedBy, null), - ...entryLevelPublishingMetaFields, - version, status, locked, diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts index 67a392fe99e..e976645b354 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts @@ -80,20 +80,21 @@ export const createEntryRevisionFromData = async ({ version: nextVersion, /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. + * Entry-level meta fields. 👇 */ + createdOn: getDate(rawInput.createdOn, latestStorageEntry.createdOn), savedOn: getDate(rawInput.savedOn, currentDateTime), - createdOn: getDate(rawInput.createdOn, currentDateTime), - publishedOn: getDate(rawInput.publishedOn, originalEntry.publishedOn), - createdBy: getIdentity(rawInput.createdBy, originalEntry.createdBy), - modifiedBy: getIdentity(rawInput.modifiedBy, null), - ownedBy: getIdentity(rawInput.ownedBy, originalEntry.ownedBy), - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ + modifiedOn: getDate(rawInput.modifiedOn, currentDateTime), + firstPublishedOn: getDate(rawInput.firstPublishedOn, latestStorageEntry.firstPublishedOn), + lastPublishedOn: getDate(rawInput.lastPublishedOn, latestStorageEntry.lastPublishedOn), + createdBy: getIdentity(rawInput.createdBy, latestStorageEntry.createdBy), + savedBy: getIdentity(rawInput.savedBy, currentIdentity), + modifiedBy: getIdentity(rawInput.modifiedBy, currentIdentity), + firstPublishedBy: getIdentity( + rawInput.firstPublishedBy, + latestStorageEntry.firstPublishedBy + ), + lastPublishedBy: getIdentity(rawInput.lastPublishedBy, latestStorageEntry.lastPublishedBy), /** * Revision-level meta fields. 👇 @@ -109,32 +110,6 @@ export const createEntryRevisionFromData = async ({ revisionFirstPublishedBy: getIdentity(rawInput.revisionFirstPublishedBy, null), revisionLastPublishedBy: getIdentity(rawInput.revisionLastPublishedBy, null), - /** - * Entry-level meta fields. 👇 - */ - entryCreatedOn: getDate(rawInput.entryCreatedOn, latestStorageEntry.entryCreatedOn), - entrySavedOn: getDate(rawInput.entrySavedOn, currentDateTime), - entryModifiedOn: getDate(rawInput.entryModifiedOn, currentDateTime), - entryFirstPublishedOn: getDate( - rawInput.entryFirstPublishedOn, - latestStorageEntry.entryFirstPublishedOn - ), - entryLastPublishedOn: getDate( - rawInput.entryLastPublishedOn, - latestStorageEntry.entryLastPublishedOn - ), - entryCreatedBy: getIdentity(rawInput.entryCreatedBy, latestStorageEntry.entryCreatedBy), - entrySavedBy: getIdentity(rawInput.entrySavedBy, currentIdentity), - entryModifiedBy: getIdentity(rawInput.entryModifiedBy, currentIdentity), - entryFirstPublishedBy: getIdentity( - rawInput.entryFirstPublishedBy, - latestStorageEntry.entryFirstPublishedBy - ), - entryLastPublishedBy: getIdentity( - rawInput.entryLastPublishedBy, - latestStorageEntry.entryLastPublishedBy - ), - locked: false, status: STATUS_DRAFT, values diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createPublishEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createPublishEntryData.ts index 88d86c6ff63..5d9a8f86f1d 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createPublishEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createPublishEntryData.ts @@ -1,11 +1,10 @@ -import { CmsContext, CmsEntry, CmsModel, CmsPublishEntryOptions } from "~/types"; +import { CmsContext, CmsEntry, CmsModel } from "~/types"; import { STATUS_PUBLISHED } from "./statuses"; import { SecurityIdentity } from "@webiny/api-security/types"; import { validateModelEntryDataOrThrow } from "~/crud/contentEntry/entryDataValidation"; type CreatePublishEntryDataParams = { model: CmsModel; - options?: CmsPublishEntryOptions; context: CmsContext; getIdentity: () => SecurityIdentity; originalEntry: CmsEntry; @@ -14,7 +13,6 @@ type CreatePublishEntryDataParams = { export const createPublishEntryData = async ({ model, - options, context, getIdentity: getSecurityIdentity, originalEntry, @@ -32,39 +30,24 @@ export const createPublishEntryData = async ({ const currentDateTime = new Date().toISOString(); const currentIdentity = getSecurityIdentity(); - /** - * The existing functionality is to set the publishedOn date to the current date. - * Users can now choose to skip updating the publishedOn date - unless it is not set. - * - * Same logic goes for the savedOn date. - */ - const { updatePublishedOn = true, updateSavedOn = true } = options || {}; - let publishedOn = originalEntry.publishedOn; - if (updatePublishedOn || !publishedOn) { - publishedOn = currentDateTime; - } - - let savedOn = originalEntry.savedOn; - if (updateSavedOn || !savedOn) { - savedOn = currentDateTime; - } - const entry: CmsEntry = { ...originalEntry, status: STATUS_PUBLISHED, locked: true, /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - savedOn, - publishedOn, - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. + * Entry-level meta fields. 👇 */ + createdOn: latestEntry.createdOn, + modifiedOn: currentDateTime, + savedOn: currentDateTime, + firstPublishedOn: latestEntry.firstPublishedOn || currentDateTime, + lastPublishedOn: currentDateTime, + createdBy: latestEntry.createdBy, + modifiedBy: currentIdentity, + savedBy: currentIdentity, + firstPublishedBy: latestEntry.firstPublishedBy || currentIdentity, + lastPublishedBy: currentIdentity, /** * Revision-level meta fields. 👇 @@ -78,21 +61,7 @@ export const createPublishEntryData = async ({ revisionSavedBy: currentIdentity, revisionModifiedBy: currentIdentity, revisionFirstPublishedBy: originalEntry.revisionFirstPublishedBy || currentIdentity, - revisionLastPublishedBy: currentIdentity, - - /** - * Entry-level meta fields. 👇 - */ - entryCreatedOn: latestEntry.entryCreatedOn, - entrySavedOn: currentDateTime, - entryModifiedOn: currentDateTime, - entryFirstPublishedOn: latestEntry.entryFirstPublishedOn || currentDateTime, - entryLastPublishedOn: currentDateTime, - entryCreatedBy: latestEntry.entryCreatedBy, - entrySavedBy: currentIdentity, - entryModifiedBy: currentIdentity, - entryFirstPublishedBy: latestEntry.entryFirstPublishedBy || currentIdentity, - entryLastPublishedBy: currentIdentity + revisionLastPublishedBy: currentIdentity }; return { entry }; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts index 7494ec7e973..cf28c1bb458 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts @@ -1,5 +1,4 @@ import { CmsContext, CmsEntry, CmsModel } from "~/types"; -import { getDate } from "~/utils/date"; import { referenceFieldsMapping } from "~/crud/contentEntry/referenceFieldsMapping"; import { STATUS_PUBLISHED } from "./statuses"; import { SecurityIdentity } from "@webiny/api-security/types"; @@ -34,16 +33,16 @@ export const createRepublishEntryData = async ({ status: STATUS_PUBLISHED, /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - publishedOn: getDate(originalEntry.publishedOn, currentDateTime), - savedOn: getDate(originalEntry.savedOn, currentDateTime), - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. + * Entry-level meta fields. 👇 */ + savedOn: currentDateTime, + modifiedOn: currentDateTime, + savedBy: currentIdentity, + modifiedBy: currentIdentity, + firstPublishedOn: originalEntry.firstPublishedOn || currentDateTime, + firstPublishedBy: originalEntry.firstPublishedBy || currentIdentity, + lastPublishedOn: currentDateTime, + lastPublishedBy: currentIdentity, /** * Revision-level meta fields. 👇 @@ -57,18 +56,6 @@ export const createRepublishEntryData = async ({ revisionLastPublishedOn: currentDateTime, revisionLastPublishedBy: currentIdentity, - /** - * Entry-level meta fields. 👇 - */ - entrySavedOn: currentDateTime, - entryModifiedOn: currentDateTime, - entrySavedBy: currentIdentity, - entryModifiedBy: currentIdentity, - entryFirstPublishedOn: originalEntry.entryFirstPublishedOn || currentDateTime, - entryFirstPublishedBy: originalEntry.entryFirstPublishedBy || currentIdentity, - entryLastPublishedOn: currentDateTime, - entryLastPublishedBy: currentIdentity, - webinyVersion: context.WEBINY_VERSION, values }; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts index 7516c6016f4..b9202e6e2cd 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts @@ -23,10 +23,12 @@ export const createUnpublishEntryData = async ({ status: STATUS_UNPUBLISHED, /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - * We want to update savedX and modifiedX fields on both revision and entry levels. + * Entry-level meta fields. 👇 */ + savedOn: currentDateTime, + modifiedOn: currentDateTime, + savedBy: currentIdentity, + modifiedBy: currentIdentity, /** * Revision-level meta fields. 👇 @@ -34,15 +36,7 @@ export const createUnpublishEntryData = async ({ revisionSavedOn: currentDateTime, revisionModifiedOn: currentDateTime, revisionSavedBy: currentIdentity, - revisionModifiedBy: currentIdentity, - - /** - * Entry-level meta fields. 👇 - */ - entrySavedOn: currentDateTime, - entryModifiedOn: currentDateTime, - entrySavedBy: currentIdentity, - entryModifiedBy: currentIdentity + revisionModifiedBy: currentIdentity }; return { entry }; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts index d4923bf0e12..2b1416d25c5 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts @@ -86,28 +86,12 @@ export const createUpdateEntryData = async ({ const entry: CmsEntry = { ...originalEntry, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - savedOn: getDate(rawInput.savedOn, new Date()), - createdOn: getDate(rawInput.createdOn, originalEntry.createdOn), - publishedOn: getDate(rawInput.publishedOn, originalEntry.publishedOn), - createdBy: getIdentity(rawInput.createdBy, originalEntry.createdBy), - modifiedBy: getIdentity(rawInput.modifiedBy, getSecurityIdentity()), - ownedBy: getIdentity(rawInput.ownedBy, originalEntry.ownedBy), - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ revisionCreatedOn: getDate(rawInput.revisionCreatedOn, originalEntry.revisionCreatedOn), - revisionSavedOn: getDate(rawInput.revisionSavedOn, currentDateTime), revisionModifiedOn: getDate(rawInput.revisionModifiedOn, currentDateTime), + revisionSavedOn: getDate(rawInput.revisionSavedOn, currentDateTime), revisionFirstPublishedOn: getDate( rawInput.revisionFirstPublishedOn, originalEntry.revisionFirstPublishedOn @@ -117,8 +101,8 @@ export const createUpdateEntryData = async ({ originalEntry.revisionLastPublishedOn ), revisionCreatedBy: getIdentity(rawInput.revisionCreatedBy, originalEntry.revisionCreatedBy), + revisionModifiedBy: getIdentity(rawInput.revisionModifiedBy, currentIdentity), revisionSavedBy: getIdentity(rawInput.revisionSavedBy, currentIdentity), - revisionModifiedBy: getIdentity(rawInput.revisionSavedBy, currentIdentity), revisionFirstPublishedBy: getIdentity( rawInput.revisionFirstPublishedBy, originalEntry.revisionFirstPublishedBy @@ -133,28 +117,16 @@ export const createUpdateEntryData = async ({ * If required, within storage operations, these entry-level updates * will be propagated to the latest revision too. */ - entryCreatedOn: getDate(rawInput.entryCreatedOn, originalEntry.entryCreatedOn), - entrySavedOn: getDate(rawInput.entrySavedOn, currentDateTime), - entryModifiedOn: getDate(rawInput.entryModifiedOn, currentDateTime), - entryFirstPublishedOn: getDate( - rawInput.entryFirstPublishedOn, - originalEntry.entryFirstPublishedOn - ), - entryLastPublishedOn: getDate( - rawInput.entryLastPublishedOn, - originalEntry.entryLastPublishedOn - ), - entryCreatedBy: getIdentity(rawInput.entryCreatedBy, originalEntry.entryCreatedBy), - entrySavedBy: getIdentity(rawInput.revisionSavedBy, currentIdentity), - entryModifiedBy: getIdentity(rawInput.entryModifiedBy, currentIdentity), - entryFirstPublishedBy: getIdentity( - rawInput.entryFirstPublishedBy, - originalEntry.entryFirstPublishedBy - ), - entryLastPublishedBy: getIdentity( - rawInput.entryLastPublishedBy, - originalEntry.entryLastPublishedBy - ), + createdOn: getDate(rawInput.createdOn, originalEntry.createdOn), + savedOn: getDate(rawInput.savedOn, currentDateTime), + modifiedOn: getDate(rawInput.modifiedOn, currentDateTime), + firstPublishedOn: getDate(rawInput.firstPublishedOn, originalEntry.firstPublishedOn), + lastPublishedOn: getDate(rawInput.lastPublishedOn, originalEntry.lastPublishedOn), + createdBy: getIdentity(rawInput.createdBy, originalEntry.createdBy), + savedBy: getIdentity(rawInput.savedBy, currentIdentity), + modifiedBy: getIdentity(rawInput.modifiedBy, currentIdentity), + firstPublishedBy: getIdentity(rawInput.firstPublishedBy, originalEntry.firstPublishedBy), + lastPublishedBy: getIdentity(rawInput.lastPublishedBy, originalEntry.lastPublishedBy), values, meta, diff --git a/packages/api-headless-cms/src/export/crud/imports/importData.ts b/packages/api-headless-cms/src/export/crud/imports/importData.ts index 4abd0229342..edfc82b7166 100644 --- a/packages/api-headless-cms/src/export/crud/imports/importData.ts +++ b/packages/api-headless-cms/src/export/crud/imports/importData.ts @@ -20,18 +20,48 @@ interface Response { error?: string; } +interface GetGroupParams { + validated: ValidCmsGroupResult[]; + imported: CmsGroupImportResult[]; + target: string; +} + +const getGroup = (params: GetGroupParams) => { + const { validated, imported, target } = params; + const group = imported.find(group => { + return group.group.id === target; + }); + if (group) { + return group.group.id; + } + const validatedGroup = validated.find(group => { + return group.group.id === target || group.target === target; + }); + return validatedGroup?.target || validatedGroup?.group.id; +}; + export const importData = async (params: Params): Promise => { const { context } = params; const groups = await importGroups(params); - const importModelResults = await importModels({ + const models = await importModels({ context, - models: params.models + models: params.models.map(model => { + const group = getGroup({ + validated: params.groups, + imported: groups, + target: model.model.group + }); + return { + ...model, + group: group || model.model.group + }; + }) }); return { groups, - models: importModelResults + models }; }; diff --git a/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts b/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts index b7a550a3c90..cb2defd5038 100644 --- a/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts +++ b/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts @@ -89,15 +89,21 @@ export const validateGroups = async (params: Params): Promise g.slug === data.slug); + const groupWithSlugExists = groups.find(g => g.slug === data.slug); if (groupWithSlugExists) { + /** + * If group with given slug already exists, we will map the ID to the existing group. + * + * We will also point all models from the imported group to the existing group. + * We will not update the existing group. + */ return { - group: data, - action: CmsImportAction.NONE, - error: { - message: `Group with slug "${data.slug}" already exists. Cannot update because the ID is different.`, - code: "GROUP_SLUG_EXISTS" - } + group: { + ...data, + id: groupWithSlugExists.id + }, + target: data.id, + action: CmsImportAction.NONE }; } diff --git a/packages/api-headless-cms/src/export/crud/imports/validateInput.ts b/packages/api-headless-cms/src/export/crud/imports/validateInput.ts index 48d3a6aaa4e..74cec0b53a8 100644 --- a/packages/api-headless-cms/src/export/crud/imports/validateInput.ts +++ b/packages/api-headless-cms/src/export/crud/imports/validateInput.ts @@ -25,6 +25,10 @@ interface InvalidResponse { error?: string; } +interface InputGroup extends Pick { + target: string; +} + export const validateInput = async (params: Params): Promise => { const { groups, models, data } = params; @@ -33,19 +37,28 @@ export const validateInput = async (params: Params): Promise { + const inputGroups = validatedGroups.reduce( + (collection, data) => { if (!data.group?.id) { - return null; + return collection; } + collection.push({ + id: data.group.id, + target: data.target || data.group.id, + slug: data.group.slug + }); + return collection; + }, + groups.map(g => { return { - id: data.group?.id + id: g.id, + target: g.id, + slug: g.slug }; }) - .filter(Boolean) as Pick[]; - + ); const validatedModels = await validateModels({ - groups: groups.map(g => ({ id: g.id })).concat(ids), + groups: inputGroups, models, input: data.models }); diff --git a/packages/api-headless-cms/src/export/crud/imports/validateModels.ts b/packages/api-headless-cms/src/export/crud/imports/validateModels.ts index bb7a6e42e50..d1d540e2f14 100644 --- a/packages/api-headless-cms/src/export/crud/imports/validateModels.ts +++ b/packages/api-headless-cms/src/export/crud/imports/validateModels.ts @@ -156,8 +156,12 @@ const validateModel = (params: CreateModelValidationParams): ValidationResult => }; }; +interface InputGroup extends Pick { + target: string; +} + interface Params { - groups: Pick[]; + groups: InputGroup[]; models: CmsModel[]; input: HeadlessCmsImportStructureParamsDataModel[]; } @@ -215,7 +219,7 @@ export const validateModels = async (params: Params): Promise g.id === data.group); + const group = groups.find(g => g.id === data.group || g.target === data.group); if (!group) { return { model: data, @@ -236,23 +240,28 @@ export const validateModels = async (params: Params): Promise { const isNullable = isNullableEntryMetaField(field) ? "" : "!"; const fieldType = isDateTimeEntryMetaField(field) ? "DateTime" : "CmsIdentity"; @@ -374,11 +348,11 @@ export const createContentEntriesSchema = ({ entryId: String! model: CmsModelMeta! status: String! + published: CmsPublishedContentEntry title: String! description: String image: String - ${deprecatedOnByMetaFields} ${onByMetaFields} wbyAco_location: WbyAcoLocation diff --git a/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts b/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts index daf457a4184..21e1ffd0265 100644 --- a/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts +++ b/packages/api-headless-cms/src/graphql/schema/createManageSDL.ts @@ -69,17 +69,6 @@ export const createManageSDL: CreateManageSDL = ({ return `${field}: ${fieldType}`; }).join("\n"); - /** - * TODO check for 5.38.0 - */ - const deprecatedOnByMetaGqlFields = [ - `createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn''.")`, - `savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.")`, - `createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.")`, - `ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedBy.")`, - `modifiedBy: CmsIdentity @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.")` - ].join("\n"); - const onByMetaGqlFields = ENTRY_META_FIELDS.map(field => { const isNullable = isNullableEntryMetaField(field) ? "" : "!"; const fieldType = isDateTimeEntryMetaField(field) ? "DateTime" : "CmsIdentity"; @@ -94,7 +83,6 @@ export const createManageSDL: CreateManageSDL = ({ id: ID! entryId: String! - ${deprecatedOnByMetaGqlFields} ${onByMetaGqlFields} meta: ${singularName}Meta @@ -107,7 +95,6 @@ export const createManageSDL: CreateManageSDL = ({ modelId: String version: Int locked: Boolean - publishedOn: DateTime status: String """ @@ -134,24 +121,6 @@ export const createManageSDL: CreateManageSDL = ({ # Set status of the entry. status: String - # Set a different date/time as the creation date/time of the entry. - createdOn: DateTime @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.") - - # Set a different date/time as the last modification date/time of the entry. - savedOn: DateTime @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.") - - # Set a different date/time as the publication date/time of the entry. - publishedOn: DateTime @deprecated(reason: "Use 'revisionPublishedOn' or 'entryPublishedOn'.") - - # Set a different identity as the creator of the entry. - createdBy: CmsIdentityInput @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.") - - # Set a different identity as the last editor of the entry. - modifiedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionModifiedBy' or 'entryModifiedBy'.") - - # Set a different identity as the owner of the entry. - ownedBy: CmsIdentityInput @deprecated(reason: "Use 'revisionOwnedBy' or 'entryOwnedBy'.") - ${onByMetaInputGqlFields} wbyAco_location: WbyAcoLocationInput @@ -229,7 +198,7 @@ export const createManageSDL: CreateManageSDL = ({ deleteMultiple${pluralName}(entries: [ID!]!): CmsDeleteMultipleResponse! - publish${singularName}(revision: ID!, options: CmsPublishEntryOptionsInput): ${singularName}Response + publish${singularName}(revision: ID!): ${singularName}Response republish${singularName}(revision: ID!): ${singularName}Response diff --git a/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts b/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts index deaa3fe817c..8fac945cdcf 100644 --- a/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts +++ b/packages/api-headless-cms/src/graphql/schema/createReadSDL.ts @@ -57,13 +57,6 @@ export const createReadSDL: CreateReadSDL = ({ const { singularApiName: singularName, pluralApiName: pluralName } = model; - const deprecatedOnByMetaFields = [ - `createdOn: DateTime! @deprecated(reason: "Use 'revisionCreatedOn' or 'entryCreatedOn'.")`, - `savedOn: DateTime! @deprecated(reason: "Use 'revisionSavedOn' or 'entrySavedOn'.")`, - `createdBy: CmsIdentity! @deprecated(reason: "Use 'revisionCreatedBy' or 'entryCreatedBy'.")`, - `ownedBy: CmsIdentity! @deprecated(reason: "Use 'entryCreatedOn'.")` - ].join("\n"); - const onByMetaFields = ENTRY_META_FIELDS.map(field => { const isNullable = isNullableEntryMetaField(field) ? "" : "!"; const fieldType = isDateTimeEntryMetaField(field) ? "DateTime" : "CmsIdentity"; @@ -78,7 +71,6 @@ export const createReadSDL: CreateReadSDL = ({ entryId: String! ${hasModelIdField ? "" : "modelId: String!"} - ${deprecatedOnByMetaFields} ${onByMetaFields} ${fieldsRender.map(f => f.fields).join("\n")} diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/manage/resolvePublish.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/manage/resolvePublish.ts index a61f425c5f9..18b02e9edf7 100644 --- a/packages/api-headless-cms/src/graphql/schema/resolvers/manage/resolvePublish.ts +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/manage/resolvePublish.ts @@ -1,9 +1,8 @@ import { ErrorResponse, Response } from "@webiny/handler-graphql/responses"; -import { CmsEntryResolverFactory as ResolverFactory, CmsPublishEntryOptions } from "~/types"; +import { CmsEntryResolverFactory as ResolverFactory } from "~/types"; interface ResolvePublishArgs { revision: string; - options?: CmsPublishEntryOptions; } type ResolvePublish = ResolverFactory; @@ -12,7 +11,7 @@ export const resolvePublish: ResolvePublish = ({ model }) => async (_, args: any, context) => { try { - const entry = await context.cms.publishEntry(model, args.revision, args.options); + const entry = await context.cms.publishEntry(model, args.revision); return new Response(entry); } catch (e) { return new ErrorResponse(e); diff --git a/packages/api-headless-cms/src/graphqlFields/index.ts b/packages/api-headless-cms/src/graphqlFields/index.ts index 4de7e77f8d4..a558071477a 100644 --- a/packages/api-headless-cms/src/graphqlFields/index.ts +++ b/packages/api-headless-cms/src/graphqlFields/index.ts @@ -9,6 +9,7 @@ import { createFileField } from "./file"; import { createObjectField } from "./object"; import { createDynamicZoneField } from "~/graphqlFields/dynamicZone"; import { CmsModelFieldToGraphQLPlugin } from "~/types"; +import { createJsonField } from "~/graphqlFields/json"; export const createGraphQLFields = (): CmsModelFieldToGraphQLPlugin[] => [ createTextField(), @@ -18,6 +19,7 @@ export const createGraphQLFields = (): CmsModelFieldToGraphQLPlugin[] => [ createBooleanField(), createLongTextField(), createRichTextField(), + createJsonField(), createFileField(), createObjectField(), createDynamicZoneField() diff --git a/packages/api-headless-cms/src/graphqlFields/json.ts b/packages/api-headless-cms/src/graphqlFields/json.ts new file mode 100644 index 00000000000..dcb43bdddda --- /dev/null +++ b/packages/api-headless-cms/src/graphqlFields/json.ts @@ -0,0 +1,35 @@ +import { CmsModelFieldToGraphQLPlugin } from "~/types"; +import { createGraphQLInputField } from "./helpers"; + +export const createJsonField = (): CmsModelFieldToGraphQLPlugin => { + return { + name: "cms-model-field-to-graphql-json", + type: "cms-model-field-to-graphql", + fieldType: "json", + isSortable: false, + isSearchable: false, + read: { + createTypeField({ field }) { + if (field.multipleValues) { + return `${field.fieldId}: [JSON]`; + } + + return `${field.fieldId}: JSON`; + }, + createGetFilters({ field }) { + return `${field.fieldId}: JSON`; + } + }, + manage: { + createTypeField({ field }) { + if (field.multipleValues) { + return `${field.fieldId}: [JSON]`; + } + return `${field.fieldId}: JSON`; + }, + createInputField({ field }) { + return createGraphQLInputField(field, "JSON"); + } + } + }; +}; diff --git a/packages/api-headless-cms/src/index.ts b/packages/api-headless-cms/src/index.ts index 47a1d186c1f..cb27be99663 100644 --- a/packages/api-headless-cms/src/index.ts +++ b/packages/api-headless-cms/src/index.ts @@ -2,8 +2,6 @@ import { createGraphQL as baseCreateGraphQL, CreateGraphQLParams } from "~/graph import { createDefaultModelManager } from "~/modelManager"; import { createGraphQLFields } from "~/graphqlFields"; import { createValidators } from "~/validators"; -import { createDefaultStorageTransform } from "~/storage/default"; -import { createObjectStorageTransform } from "~/storage/object"; import { createDynamicZoneStorageTransform } from "~/graphqlFields/dynamicZone/dynamicZoneStorage"; import { createContextParameterPlugin, @@ -18,6 +16,7 @@ import { } from "./utils/entryStorage"; import { createFieldConverters } from "~/fieldConverters"; import { createExportGraphQL } from "~/export"; +import { createStorageTransform } from "~/storage"; export type CreateHeadlessCmsGraphQLParams = CreateGraphQLParams; export const createHeadlessCmsGraphQL = (params: CreateHeadlessCmsGraphQLParams = {}) => { @@ -47,8 +46,7 @@ export const createHeadlessCmsContext = (params: ContentContextParams) => { createGraphQLFields(), createFieldConverters(), createValidators(), - createDefaultStorageTransform(), - createObjectStorageTransform(), + ...createStorageTransform(), createDynamicZoneStorageTransform() ]; }; diff --git a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts index f4701851d96..de90aa20bf9 100644 --- a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts +++ b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts @@ -43,7 +43,7 @@ interface CmsModelFieldInput extends Omit { isPrivate?: never; @@ -60,7 +60,7 @@ export interface CmsApiModelFull extends Omit { noValidate?: never; @@ -77,7 +77,8 @@ export interface CmsPrivateModelFull extends Omit { +export interface CmsModelPluginModel + extends Omit { locale?: string; tenant?: string; } @@ -88,7 +89,7 @@ interface CmsModelPluginOptions { export class CmsModelPlugin extends Plugin { public static override readonly type: string = "cms-content-model"; - public readonly contentModel: CmsModel; + public readonly contentModel: CmsModelPluginModel; private readonly options: CmsModelPluginOptions; @@ -98,7 +99,7 @@ export class CmsModelPlugin extends Plugin { this.contentModel = this.buildModel(contentModel); } - private buildModel(input: CmsModelInput): CmsModel { + private buildModel(input: CmsModelInput): CmsModelPluginModel { const isPrivate = input.isPrivate || false; const singularApiName = input.singularApiName ? createApiName(input.singularApiName) @@ -122,7 +123,7 @@ export class CmsModelPlugin extends Plugin { }; } - const model: CmsModel = { + const model: CmsModelPluginModel = { ...input, isPlugin: true, isPrivate, @@ -269,7 +270,7 @@ export class CmsModelPlugin extends Plugin { return fields; } - private validateLayout(model: CmsModel): void { + private validateLayout(model: CmsModelPluginModel): void { /** * Only skip validation if option.validateLayout was set as false, explicitly. */ diff --git a/packages/api-headless-cms/src/plugins/StorageTransformPlugin.ts b/packages/api-headless-cms/src/plugins/StorageTransformPlugin.ts index 5a9af1624a9..4236821da29 100644 --- a/packages/api-headless-cms/src/plugins/StorageTransformPlugin.ts +++ b/packages/api-headless-cms/src/plugins/StorageTransformPlugin.ts @@ -1,5 +1,5 @@ import { Plugin } from "@webiny/plugins/Plugin"; -import { CmsModel, CmsModelField } from "~/types"; +import { CmsModel, CmsModelField, CmsModelFieldType } from "~/types"; import { PluginsContainer } from "@webiny/plugins"; export interface ToStorageParams { @@ -20,7 +20,7 @@ export interface FromStorageParams { export interface StorageTransformPluginParams { name?: string; - fieldType: string; + fieldType: CmsModelFieldType; toStorage: (params: ToStorageParams) => Promise; fromStorage: (params: FromStorageParams) => Promise; } diff --git a/packages/api-headless-cms/src/storage/index.ts b/packages/api-headless-cms/src/storage/index.ts new file mode 100644 index 00000000000..6183b842dc8 --- /dev/null +++ b/packages/api-headless-cms/src/storage/index.ts @@ -0,0 +1,11 @@ +import { createDefaultStorageTransform } from "./default"; +import { createObjectStorageTransform } from "./object"; +import { createJsonStorageTransform } from "./json"; + +export const createStorageTransform = () => { + return [ + createDefaultStorageTransform(), + createObjectStorageTransform(), + createJsonStorageTransform() + ]; +}; diff --git a/packages/api-headless-cms/src/storage/json.ts b/packages/api-headless-cms/src/storage/json.ts new file mode 100644 index 00000000000..c6e8578c457 --- /dev/null +++ b/packages/api-headless-cms/src/storage/json.ts @@ -0,0 +1,14 @@ +import { StorageTransformPlugin } from "~/plugins/StorageTransformPlugin"; +// TODO implement compression +export const createJsonStorageTransform = (): StorageTransformPlugin => { + return new StorageTransformPlugin({ + name: "headless-cms.storage-transform.json", + fieldType: "json", + fromStorage: async ({ value }) => { + return value; + }, + toStorage: async ({ value }) => { + return value; + } + }); +}; diff --git a/packages/api-headless-cms/src/types.ts b/packages/api-headless-cms/src/types.ts index ab471da53d5..0bec0ce616d 100644 --- a/packages/api-headless-cms/src/types.ts +++ b/packages/api-headless-cms/src/types.ts @@ -165,6 +165,7 @@ export type CmsModelFieldType = | "file" | "long-text" | "number" + | "json" | "object" | "ref" | "rich-text" @@ -647,7 +648,7 @@ export interface CmsModelFieldToGraphQLPlugin { */ id: string; - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - - /** - * @deprecated Use `revisionCreatedBy` or `entryCreatedBy` instead. - * CreatedBy object reference. - */ - createdBy: CmsIdentity; - /** - * @deprecated Use `entryCreatedBy` instead. - * OwnedBy object reference. Can be different from CreatedBy. - */ - ownedBy: CmsIdentity; - /** - * @deprecated Use `revisionModifiedBy` or `entryModifiedBy` instead. - * ModifiedBy object reference. Last person who modified the entry. - */ - modifiedBy?: CmsIdentity | null; - /** - * @deprecated Use `revisionCreatedOn` or `entryCreatedOn` instead. - * A string of Date.toISOString() type. - * Populated on creation. - */ - createdOn: string; - /** - * @deprecated Use `revisionSavedOn` or `entrySavedOn` instead. - * A string of Date.toISOString() type. - * Populated every time entry is saved. - */ - savedOn: string; - /** - * @deprecated Use `entryLastPublishedOn` or `entryFirstPublishedOn` instead. - * A string of Date.toISOString() type - if published. - * Populated when entry is published. - */ - publishedOn?: string | null; - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ @@ -1554,51 +1511,47 @@ export interface CmsEntry { */ revisionLastPublishedBy: CmsIdentity | null; - /** - * Entry-level meta fields. 👇 - */ - /** * An ISO 8601 date/time string. */ - entryCreatedOn: string; + createdOn: string; /** * An ISO 8601 date/time string. */ - entrySavedOn: string; + savedOn: string; /** * An ISO 8601 date/time string. */ - entryModifiedOn: string | null; + modifiedOn: string | null; /** * An ISO 8601 date/time string. */ - entryFirstPublishedOn: string | null; + firstPublishedOn: string | null; /** * An ISO 8601 date/time string. */ - entryLastPublishedOn: string | null; + lastPublishedOn: string | null; /** * Identity that last created the entry. */ - entryCreatedBy: CmsIdentity; + createdBy: CmsIdentity; /** * Identity that last saved the entry. */ - entrySavedBy: CmsIdentity; + savedBy: CmsIdentity; /** * Identity that last modified the entry. */ - entryModifiedBy: CmsIdentity | null; + modifiedBy: CmsIdentity | null; /** * Identity that first published the entry. */ - entryFirstPublishedBy: CmsIdentity | null; + firstPublishedBy: CmsIdentity | null; /** * Identity that last published the entry. */ - entryLastPublishedBy: CmsIdentity | null; + lastPublishedBy: CmsIdentity | null; /** * Model ID of the definition for the entry. @@ -1912,35 +1865,6 @@ export interface CmsEntryListWhere { entryId_in?: string[]; entryId_not_in?: string[]; - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - - /** - * Contains the owner of the entry. An "owner" is the identity who originally created the entry. - * Subsequent revisions can be created by other identities, and those will be stored in `createdBy`, - * but the `owner` is always the original author of the entry. - * - * Can be sent via the API or set internal if user can see only their own entries. - */ - ownedBy?: string; - ownedBy_not?: string; - ownedBy_in?: string[]; - ownedBy_not_in?: string[]; - /** - * Who created the entry? - */ - createdBy?: string; - createdBy_not?: string; - createdBy_in?: string[]; - createdBy_not_in?: string[]; - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ @@ -1972,25 +1896,30 @@ export interface CmsEntryListWhere { /** * Entry-level meta fields. 👇 */ - entryCreatedBy?: string; - entryCreatedBy_not?: string; - entryCreatedBy_in?: string[]; - entryCreatedBy_not_in?: string[]; + createdBy?: string; + createdBy_not?: string; + createdBy_in?: string[]; + createdBy_not_in?: string[]; - entryModifiedBy?: string; - entryModifiedBy_not?: string; - entryModifiedBy_in?: string[]; - entryModifiedBy_not_in?: string[]; + modifiedBy?: string; + modifiedBy_not?: string; + modifiedBy_in?: string[]; + modifiedBy_not_in?: string[]; - entrySavedBy?: string; - entrySavedBy_not?: string; - entrySavedBy_in?: string[]; - entrySavedBy_not_in?: string[]; + savedBy?: string; + savedBy_not?: string; + savedBy_in?: string[]; + savedBy_not_in?: string[]; - entryFirstPublishedBy?: string; - entryFirstPublishedBy_not?: string; - entryFirstPublishedBy_in?: string[]; - entryFirstPublishedBy_not_in?: string[]; + firstPublishedBy?: string; + firstPublishedBy_not?: string; + firstPublishedBy_in?: string[]; + firstPublishedBy_not_in?: string[]; + + lastPublishedBy?: string; + lastPublishedBy_not?: string; + lastPublishedBy_in?: string[]; + lastPublishedBy_not_in?: string[]; /** * Version of the entry. @@ -2337,40 +2266,32 @@ export interface CreateCmsEntryInput { status?: CmsEntryStatus; /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. + * Entry-level meta fields. 👇 */ createdOn?: Date | string; + modifiedOn?: Date | string | null; savedOn?: Date | string; - publishedOn?: Date | string; - createdBy?: CmsIdentity | null; + createdBy?: CmsIdentity; modifiedBy?: CmsIdentity | null; - ownedBy?: CmsIdentity | null; - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ + savedBy?: CmsIdentity; + firstPublishedOn?: Date | string; + lastPublishedOn?: Date | string; + firstPublishedBy?: CmsIdentity; + lastPublishedBy?: CmsIdentity; /** * Revision-level meta fields. 👇 */ revisionCreatedOn?: Date | string; - revisionSavedOn?: Date | string; revisionModifiedOn?: Date | string | null; + revisionSavedOn?: Date | string; revisionCreatedBy?: CmsIdentity; revisionModifiedBy?: CmsIdentity | null; revisionSavedBy?: CmsIdentity; - - /** - * Entry-level meta fields. 👇 - */ - entryCreatedOn?: Date | string; - entrySavedOn?: Date | string; - entryModifiedOn?: Date | string | null; - entryCreatedBy?: CmsIdentity; - entryModifiedBy?: CmsIdentity | null; - entrySavedBy?: CmsIdentity; + revisionFirstPublishedOn?: Date | string; + revisionLastPublishedOn?: Date | string; + revisionFirstPublishedBy?: CmsIdentity; + revisionLastPublishedBy?: CmsIdentity; wbyAco_location?: { folderId?: string | null; @@ -2388,22 +2309,6 @@ export interface CreateCmsEntryOptionsInput { * @category CmsEntry */ export interface CreateFromCmsEntryInput { - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - createdOn?: Date; - savedOn?: Date; - publishedOn?: Date; - createdBy?: CmsIdentity; - modifiedBy?: CmsIdentity; - ownedBy?: CmsIdentity; - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ @@ -2413,16 +2318,24 @@ export interface CreateFromCmsEntryInput { revisionCreatedBy?: CmsIdentity; revisionModifiedBy?: CmsIdentity; revisionSavedBy?: CmsIdentity; + revisionFirstPublishedOn?: Date | string; + revisionLastPublishedOn?: Date | string; + revisionFirstPublishedBy?: CmsIdentity; + revisionLastPublishedBy?: CmsIdentity; /** * Entry-level meta fields. 👇 */ - entryCreatedOn?: Date; - entrySavedOn?: Date; - entryModifiedOn?: Date; - entryCreatedBy?: CmsIdentity; - entryModifiedBy?: CmsIdentity; - entrySavedBy?: CmsIdentity; + createdOn?: Date; + savedOn?: Date; + modifiedOn?: Date; + createdBy?: CmsIdentity; + modifiedBy?: CmsIdentity; + savedBy?: CmsIdentity; + firstPublishedOn?: Date | string; + lastPublishedOn?: Date | string; + firstPublishedBy?: CmsIdentity; + lastPublishedBy?: CmsIdentity; [key: string]: any; } @@ -2436,32 +2349,16 @@ export interface CreateRevisionCmsEntryOptionsInput { * @category CmsEntry */ export interface UpdateCmsEntryInput { - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - createdOn?: Date | string | null; - savedOn?: Date | string | null; - publishedOn?: Date | string | null; - createdBy?: CmsIdentity | null; - modifiedBy?: CmsIdentity | null; - ownedBy?: CmsIdentity; - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ - /** * Revision-level meta fields. 👇 */ revisionCreatedOn?: Date | string | null; - revisionSavedOn?: Date | string | null; revisionModifiedOn?: Date | string | null; + revisionSavedOn?: Date | string | null; revisionFirstPublishedOn?: Date | string | null; revisionLastPublishedOn?: Date | string | null; - revisionCreatedBy?: CmsIdentity | null; revisionModifiedBy?: CmsIdentity | null; + revisionCreatedBy?: CmsIdentity | null; revisionSavedBy?: CmsIdentity | null; revisionFirstPublishedBy?: CmsIdentity | null; revisionLastPublishedBy?: CmsIdentity | null; @@ -2469,16 +2366,16 @@ export interface UpdateCmsEntryInput { /** * Entry-level meta fields. 👇 */ - entryCreatedOn?: Date | string | null; - entrySavedOn?: Date | string | null; - entryModifiedOn?: Date | string | null; - entryFirstPublishedOn?: Date | string | null; - entryLastPublishedOn?: Date | string | null; - entryCreatedBy?: CmsIdentity | null; - entryModifiedBy?: CmsIdentity | null; - entrySavedBy?: CmsIdentity | null; - entryFirstPublishedBy?: CmsIdentity | null; - entryLastPublishedBy?: CmsIdentity | null; + createdOn?: Date | string | null; + modifiedOn?: Date | string | null; + savedOn?: Date | string | null; + firstPublishedOn?: Date | string | null; + lastPublishedOn?: Date | string | null; + createdBy?: CmsIdentity | null; + modifiedBy?: CmsIdentity | null; + savedBy?: CmsIdentity | null; + firstPublishedBy?: CmsIdentity | null; + lastPublishedBy?: CmsIdentity | null; wbyAco_location?: { folderId?: string | null; @@ -2511,20 +2408,6 @@ export interface CmsDeleteEntryOptions { force?: boolean; } -/** - * @category CmsEntry - */ -export interface CmsPublishEntryOptions { - /** - * By default, updatePublishedOn is "true". User can set it to "false" to skip the publishedOn field update. - */ - updatePublishedOn?: boolean; - /** - * By default, updateSavedOn is "true". User can set it to "false" to skip the publishedOn field update. - */ - updateSavedOn?: boolean; -} - /** * @category Context * @category CmsEntry @@ -2651,11 +2534,7 @@ export interface CmsEntryContext { /** * Publish entry. */ - publishEntry: ( - model: CmsModel, - id: string, - options?: CmsPublishEntryOptions - ) => Promise; + publishEntry: (model: CmsModel, id: string) => Promise; /** * Unpublish entry. */ diff --git a/packages/api-headless-cms/src/utils/renderListFilterFields.ts b/packages/api-headless-cms/src/utils/renderListFilterFields.ts index cb438d6eec5..bd7760cebb4 100644 --- a/packages/api-headless-cms/src/utils/renderListFilterFields.ts +++ b/packages/api-headless-cms/src/utils/renderListFilterFields.ts @@ -36,54 +36,6 @@ export const renderListFilterFields: RenderListFilterFields = (params): string = "entryId_in: [String!]", "entryId_not_in: [String!]", - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - - // Deprecated. Use `revisionCreatedBy` instead. - "createdOn: DateTime", - "createdOn_gt: DateTime", - "createdOn_gte: DateTime", - "createdOn_lt: DateTime", - "createdOn_lte: DateTime", - "createdOn_between: [DateTime!]", - "createdOn_not_between: [DateTime!]", - - // Deprecated. Use `revisionSavedOn` instead. - "savedOn: DateTime", - "savedOn_gt: DateTime", - "savedOn_gte: DateTime", - "savedOn_lt: DateTime", - "savedOn_lte: DateTime", - "savedOn_between: [DateTime!]", - "savedOn_not_between: [DateTime!]", - - // Deprecated. Use `entryFirstPublishedOn` instead. - "publishedOn: DateTime", - "publishedOn_gt: DateTime", - "publishedOn_gte: DateTime", - "publishedOn_lt: DateTime", - "publishedOn_lte: DateTime", - "publishedOn_between: [DateTime!]", - "publishedOn_not_between: [DateTime!]", - - // Deprecated. Use `revisionCreatedBy` instead. - "createdBy: String", - "createdBy_not: String", - "createdBy_in: [String!]", - "createdBy_not_in: [String!]", - - // Deprecated. Use `entryCreatedBy` instead. - "ownedBy: String", - "ownedBy_not: String", - "ownedBy_in: [String!]", - "ownedBy_not_in: [String!]", - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ ...ENTRY_META_FIELDS.map(field => { if (isDateTimeEntryMetaField(field)) { return [ diff --git a/packages/api-headless-cms/src/utils/renderSortEnum.ts b/packages/api-headless-cms/src/utils/renderSortEnum.ts index c04b78c53ba..afb31661640 100644 --- a/packages/api-headless-cms/src/utils/renderSortEnum.ts +++ b/packages/api-headless-cms/src/utils/renderSortEnum.ts @@ -24,19 +24,6 @@ export const renderSortEnum: RenderSortEnum = ({ `id_ASC`, `id_DESC`, - /** - * 🚫 Deprecated meta fields below. - * Will be fully removed in one of the next releases. - */ - "savedOn_ASC", - "savedOn_DESC", - "createdOn_ASC", - "createdOn_DESC", - - /** - * 🆕 New meta fields below. - * Users are encouraged to use these instead of the deprecated ones above. - */ ...ENTRY_META_FIELDS.filter(isDateTimeEntryMetaField) .map(field => [`${field}_ASC`, `${field}_DESC`]) .flat() diff --git a/packages/api-i18n/src/graphql/crud/locales.crud.ts b/packages/api-i18n/src/graphql/crud/locales.crud.ts index 2eaa743e836..6ad1a6d0b27 100644 --- a/packages/api-i18n/src/graphql/crud/locales.crud.ts +++ b/packages/api-i18n/src/graphql/crud/locales.crud.ts @@ -15,6 +15,7 @@ import { NotFoundError } from "@webiny/handler-graphql"; import { createTopic } from "@webiny/pubsub"; import { Tenant } from "@webiny/api-tenancy/types"; import { LocalesPermissions } from "./permissions/LocalesPermissions"; +import { IdentityValue } from "@webiny/api-security"; export interface CreateLocalesCrudParams { context: I18NContext; @@ -159,11 +160,7 @@ export const createLocalesCrud = (params: CreateLocalesCrudParams): LocalesCRUD ...input, default: input.default === true, createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, + createdBy: IdentityValue.create(identity).getValue(), tenant: getTenantId(), webinyVersion: context.WEBINY_VERSION }; diff --git a/packages/api-i18n/src/types.ts b/packages/api-i18n/src/types.ts index 3799d195e4b..f0bc31809ae 100644 --- a/packages/api-i18n/src/types.ts +++ b/packages/api-i18n/src/types.ts @@ -19,7 +19,7 @@ interface I18NLocaleDataCreatedBy { export interface I18NLocaleData extends I18NLocale { createdOn: string; - createdBy: I18NLocaleDataCreatedBy; + createdBy: I18NLocaleDataCreatedBy | null; tenant: string; webinyVersion: string; } diff --git a/packages/api-page-builder-import-export/src/client.ts b/packages/api-page-builder-import-export/src/client.ts index d58017ed144..39edc63b706 100644 --- a/packages/api-page-builder-import-export/src/client.ts +++ b/packages/api-page-builder-import-export/src/client.ts @@ -30,11 +30,6 @@ export async function invokeHandlerClient({ httpMethod: request.method, body: request.body, headers, - /** - * Required until type augmentation works correctly. - * Keep @ts-ignore because it will not build if using @ts-expect-error. - */ - // @ts-ignore read above cookies: request.cookies }; // Invoke handler diff --git a/packages/api-security-cognito/src/createAdminUsersHooks.ts b/packages/api-security-cognito/src/createAdminUsersHooks.ts index 376f64ad885..b4c5971820a 100644 --- a/packages/api-security-cognito/src/createAdminUsersHooks.ts +++ b/packages/api-security-cognito/src/createAdminUsersHooks.ts @@ -13,13 +13,12 @@ export const createAdminUsersHooks = () => { // After a new user is created, link him to a tenant via the assigned group. adminUsers.onUserAfterCreate.subscribe(async ({ user }) => { - console.log("onUserAfterCreate", user); - /** - * TODO @ts-refactor @pavel - * Are we continuing if there is no tenant? - */ const tenant = getTenant(); + if (!tenant) { + return; + } + const data: PermissionsTenantLink["data"] = { groups: [], teams: [] }; if (user.team) { @@ -43,10 +42,6 @@ export const createAdminUsersHooks = () => { await security.createTenantLinks([ { - /** - * Check few lines up. - */ - // @ts-expect-error tenant, // IMPORTANT! // Use the `id` that was assigned in the user creation process. @@ -66,6 +61,10 @@ export const createAdminUsersHooks = () => { adminUsers.onUserAfterUpdate.subscribe(async ({ updatedUser, originalUser }) => { const tenant = getTenant(); + if (!tenant) { + return; + } + // If group/team hasn't changed, we don't need to do anything. const groupChanged = updatedUser.group !== originalUser.group; const teamChanged = updatedUser.team !== originalUser.team; @@ -111,11 +110,6 @@ export const createAdminUsersHooks = () => { await security.updateTenantLinks([ { - /** - * TODO @ts-refactor @pavel - * Same as in afterCreate method - */ - // @ts-expect-error tenant, identity: updatedUser.id, @@ -130,26 +124,16 @@ export const createAdminUsersHooks = () => { // On user delete, delete its tenant link. adminUsers.onUserAfterDelete.subscribe(async ({ user }) => { - /** - * TODO @ts-refactor @pavel - * Are we continuing if there is no tenant? - */ const tenant = getTenant(); + if (!tenant) { + return; + } + await security.deleteTenantLinks([ { - /** - * TODO @ts-refactor @pavel - * Same as in afterCreate method - */ - // @ts-expect-error tenant, - identity: user.id, - - // With 5.37.0, these tenant links not only contain group-related permissions, - // but teams-related too. The `type=group` hasn't been changed, just so the - // data migrations are easier. - type: "group" + identity: user.id } ]); }); diff --git a/packages/api-security/package.json b/packages/api-security/package.json index 816eead513b..b99004d7d32 100644 --- a/packages/api-security/package.json +++ b/packages/api-security/package.json @@ -23,7 +23,8 @@ "@webiny/utils": "0.0.0", "@webiny/validation": "0.0.0", "commodo-fields-object": "^1.0.6", - "deep-equal": "^2.0.5", + "deep-equal": "^2.2.3", + "jsonwebtoken": "^9.0.1", "minimatch": "^5.1.0" }, "devDependencies": { @@ -31,6 +32,7 @@ "@babel/core": "^7.22.8", "@babel/preset-env": "^7.22.7", "@babel/preset-typescript": "^7.22.5", + "@types/jsonwebtoken": "^9.0.2", "@webiny/api-wcp": "0.0.0", "@webiny/cli": "0.0.0", "@webiny/db-dynamodb": "0.0.0", diff --git a/packages/api-security/src/createSecurity.ts b/packages/api-security/src/createSecurity.ts index d16c85fea22..19a71c42d5e 100644 --- a/packages/api-security/src/createSecurity.ts +++ b/packages/api-security/src/createSecurity.ts @@ -1,26 +1,35 @@ import { AsyncLocalStorage } from "async_hooks"; import minimatch from "minimatch"; +import { createTopic } from "@webiny/pubsub"; +import { AaclPermission } from "@webiny/api-wcp/types"; +import { Identity } from "@webiny/api-authentication/types"; import { createAuthentication } from "@webiny/api-authentication/createAuthentication"; -import { Authorizer, Security, SecurityPermission, SecurityConfig } from "./types"; +import { + Authorizer, + Security, + SecurityPermission, + SecurityConfig, + AuthenticationToken +} from "./types"; import { createApiKeysMethods } from "~/createSecurity/createApiKeysMethods"; import { createGroupsMethods } from "~/createSecurity/createGroupsMethods"; import { createTeamsMethods } from "~/createSecurity/createTeamsMethods"; import { createSystemMethods } from "~/createSecurity/createSystemMethods"; import { createTenantLinksMethods } from "~/createSecurity/createTenantLinksMethods"; import { filterOutCustomWbyAppsPermissions } from "~/createSecurity/filterOutCustomWbyAppsPermissions"; -import { createTopic } from "@webiny/pubsub"; -import { AaclPermission } from "@webiny/api-wcp/types"; export interface GetTenant { (): string | undefined; } -const asyncLocalStorage = new AsyncLocalStorage(); +const authorizationLocalStorage = new AsyncLocalStorage(); +const identityLocalStorage = new AsyncLocalStorage(); export const createSecurity = async (config: SecurityConfig): Promise => { const authentication = createAuthentication(); const authorizers: Authorizer[] = []; + let authenticationToken: AuthenticationToken | undefined; let permissions: SecurityPermission[]; let permissionsLoader: Promise; @@ -56,6 +65,12 @@ export const createSecurity = async (config: SecurityConfig): Promise return { ...authentication, config, + async authenticate(token: string): Promise { + await authentication.authenticate(token); + if (authentication.getIdentity()) { + authenticationToken = token; + } + }, onBeforeLogin: createTopic("security.onBeforeLogin"), onLogin: createTopic("security.onLogin"), onAfterLogin: createTopic("security.onAfterLogin"), @@ -69,15 +84,30 @@ export const createSecurity = async (config: SecurityConfig): Promise getAuthorizers() { return authorizers; }, + getIdentity(): TIdentity { + const localIdentity = identityLocalStorage.getStore(); + + if (localIdentity) { + return localIdentity as TIdentity; + } + + return authentication.getIdentity(); + }, setIdentity(this: Security, identity) { authentication.setIdentity(identity); this.onIdentity.publish({ identity }); }, isAuthorizationEnabled: () => { - return asyncLocalStorage.getStore() ?? true; + return authorizationLocalStorage.getStore() ?? true; + }, + getToken(): AuthenticationToken | undefined { + return authenticationToken; + }, + withoutAuthorization(this: Security, cb: () => Promise): Promise { + return authorizationLocalStorage.run(false, cb); }, - async withoutAuthorization(this: Security, cb: () => Promise): Promise { - return await asyncLocalStorage.run(false, cb); + withIdentity(identity: Identity | undefined, cb: () => Promise): Promise { + return identityLocalStorage.run(identity, cb); }, async getPermission( this: Security, diff --git a/packages/api-security/src/index.ts b/packages/api-security/src/index.ts index ee00801f591..42cfd7b7a84 100644 --- a/packages/api-security/src/index.ts +++ b/packages/api-security/src/index.ts @@ -26,6 +26,7 @@ export interface SecurityConfig extends MultiTenancyAppConfig { export * from "./utils/AppPermissions"; export * from "./utils/getPermissionsFromSecurityGroupsForLocale"; +export * from "./utils/IdentityValue"; type Context = SecurityContext & TenancyContext & WcpContext; diff --git a/packages/api-security/src/plugins/authenticateUsingCookie.ts b/packages/api-security/src/plugins/authenticateUsingCookie.ts new file mode 100644 index 00000000000..a439d635fb6 --- /dev/null +++ b/packages/api-security/src/plugins/authenticateUsingCookie.ts @@ -0,0 +1,63 @@ +import jwt from "jsonwebtoken"; +import { createContextPlugin } from "@webiny/api"; +import { createBeforeHandlerPlugin } from "@webiny/handler"; +import { SecurityContext } from "~/types"; + +const get24HoursFromNow = () => { + const oneHour = 1000 * 60 * 60; + return new Date(new Date().getTime() + oneHour * 24); +}; + +const toDate = (timestamp: number | undefined) => { + return timestamp ? new Date(timestamp * 1000) : get24HoursFromNow(); +}; + +/** + * @internal + */ +export function authenticateUsingCookie() { + return [ + createBeforeHandlerPlugin(async context => { + const { cookies } = context.request; + const token = cookies["wby-id-token"]; + + if (!context.security.getIdentity() && token) { + try { + await context.security.authenticate(token); + } catch (err) { + console.log(err); + } + } + }), + + createContextPlugin(context => { + context.security.onLogin.subscribe(() => { + const token = context.security.getToken(); + + if (!token) { + return; + } + + // Attempt to acquire expiration from the token; fall back to "expire in 24 hours". + // In most cases the token will be a JWT token, but it can also be a custom token, or an API key. + const tokenData = jwt.decode(token, { json: true }); + const expiresOn = tokenData ? toDate(tokenData.exp) : get24HoursFromNow(); + + // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + context.reply.setCookie("wby-id-token", token, { + path: "/", + expires: expiresOn, + // Allow this cookie to be sent with cross-origin requests. + sameSite: "none", + // Only send this cookie to the server when HTTPS is used. + // NOTE: `https` requirement is ignored for `localhost. + secure: true, + // Forbids JavaScript from accessing the cookie. + httpOnly: true + }); + + context.reply.header("cache-control", `no-cache="Set-Cookie"`); + }); + }) + ]; +} diff --git a/packages/api-security/src/plugins/authenticateUsingHttpHeader.ts b/packages/api-security/src/plugins/authenticateUsingHttpHeader.ts index b01f454db1a..1ae82cecb32 100644 --- a/packages/api-security/src/plugins/authenticateUsingHttpHeader.ts +++ b/packages/api-security/src/plugins/authenticateUsingHttpHeader.ts @@ -1,6 +1,8 @@ -import { BeforeHandlerPlugin } from "@webiny/handler"; -import { SecurityContext } from "~/types"; +import { createBeforeHandlerPlugin } from "@webiny/handler"; import { Context as BaseContext } from "@webiny/handler/types"; +import { authenticateUsingCookie } from "./authenticateUsingCookie"; +import { SecurityContext } from "~/types"; +import { setupSecureHeaders } from "~/plugins/secureHeaders"; type Context = BaseContext & SecurityContext; @@ -15,13 +17,19 @@ const defaultGetHeader: GetHeader = context => { }; export const authenticateUsingHttpHeader = (getHeader: GetHeader = defaultGetHeader) => { - return new BeforeHandlerPlugin(async context => { - const token = getHeader(context); + return [ + createBeforeHandlerPlugin(async context => { + const token = getHeader(context); - if (!token) { - return; - } + if (!token) { + return; + } - await context.security.authenticate(token); - }); + await context.security.authenticate(token); + }), + // Configure strict headers (this is also a requirement to use cookies). + setupSecureHeaders(), + // Finally, we add cookie-based authentication. + authenticateUsingCookie() + ]; }; diff --git a/packages/api-security/src/plugins/secureHeaders.ts b/packages/api-security/src/plugins/secureHeaders.ts new file mode 100644 index 00000000000..eb779ba56fe --- /dev/null +++ b/packages/api-security/src/plugins/secureHeaders.ts @@ -0,0 +1,35 @@ +import { createHandlerOnRequest, ResponseHeaders } from "@webiny/handler"; + +const whitelistedHeaders = [ + "accept", + "authorization", + "cache-control", + "content-type", + "x-i18n-Locale", + "x-tenant", + "x-apollo-tracing", + "apollo-query-plan-experimental" +]; + +export const setupSecureHeaders = () => { + return createHandlerOnRequest(async (request, reply) => { + const isOptions = request.method === "OPTIONS"; + + const headers = ResponseHeaders.create(); + headers.set("access-control-allow-origin", request.headers["origin"]); + headers.set("access-control-allow-credentials", "true"); + + if (isOptions) { + headers.set("cache-control", "public, max-age=86400"); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("access-control-max-age", "86400"); + headers.set("access-control-allow-methods", "OPTIONS,POST,GET,DELETE,PUT,PATCH"); + headers.set("access-control-allow-headers", whitelistedHeaders.join(", ")); + } else { + headers.set("x-tenant", request.headers["x-tenant"] || "root"); + headers.set("vary", "origin"); + } + + reply.headers(headers.getHeaders()); + }); +}; diff --git a/packages/api-security/src/types.ts b/packages/api-security/src/types.ts index 4992a713867..89ac722d76e 100644 --- a/packages/api-security/src/types.ts +++ b/packages/api-security/src/types.ts @@ -64,6 +64,8 @@ export interface GetTeamWhere { tenant?: string; } +export type AuthenticationToken = string; + export interface Security extends Authentication { /** * @deprecated @@ -82,6 +84,11 @@ export interface Security extends Authentication>; onIdentity: Topic>; + /** + * Returns the token which was used to authenticate (if authentication was successful). + */ + getToken(): AuthenticationToken | undefined; + config: SecurityConfig; getStorageOperations(): SecurityStorageOperations; @@ -89,6 +96,7 @@ export interface Security extends Authentication(cb: () => Promise): Promise; + withIdentity(identity: Identity | undefined, cb: () => Promise): Promise; addAuthorizer(authorizer: Authorizer): void; diff --git a/packages/api-security/src/utils/IdentityValue.ts b/packages/api-security/src/utils/IdentityValue.ts new file mode 100644 index 00000000000..d0dba9700ac --- /dev/null +++ b/packages/api-security/src/utils/IdentityValue.ts @@ -0,0 +1,37 @@ +import { SecurityIdentity } from "~/types"; + +type IdentityInput = SecurityIdentity | null | undefined; + +export class IdentityValue { + private readonly value: IdentityInput; + + private constructor(value: IdentityInput) { + this.value = value; + } + + static create(identity: IdentityInput) { + return new IdentityValue(identity); + } + + getValue() { + if (!this.value) { + return null; + } + + return { + id: this.value.id, + displayName: this.value.displayName, + type: this.value.type + }; + } + + getValueOrFallback(fallback: IdentityInput) { + const value = this.getValue(); + + if (value) { + return value; + } + + return IdentityValue.create(fallback).getValue(); + } +} diff --git a/packages/api-wcp/src/index.ts b/packages/api-wcp/src/index.ts index 6d745161e06..8655a1b5775 100644 --- a/packages/api-wcp/src/index.ts +++ b/packages/api-wcp/src/index.ts @@ -2,6 +2,6 @@ * We have separated context and GraphQL creation so user can initialize only context if required. * GraphQL will not work without context, but context will without GraphQL. */ - +export { createWcp } from "./createWcp"; export * from "./context"; export * from "./graphql"; diff --git a/packages/api/__tests__/ServiceDiscovery.test.ts b/packages/api/__tests__/ServiceDiscovery.test.ts new file mode 100644 index 00000000000..5def45a9af6 --- /dev/null +++ b/packages/api/__tests__/ServiceDiscovery.test.ts @@ -0,0 +1,67 @@ +import { PutCommand } from "@webiny/aws-sdk/client-dynamodb"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import { ServiceDiscovery } from "~/ServiceDiscovery"; + +describe("Service Discovery", () => { + it("should load service manifests and combine them into one manifest object", async () => { + const client = getDocumentClient(); + + // For testing, since we don't have a proper DI container yet, we need to inject a DB client to use. + ServiceDiscovery.setDocumentClient(client); + + await client.send( + new PutCommand({ + TableName: process.env.DB_TABLE, + Item: { + PK: "SERVICE_MANIFEST#api#api", + SK: "default", + GSI1_PK: "SERVICE_MANIFESTS", + GSI1_SK: "api#api", + data: { + name: "api", + manifest: { + cloudfront: { + distributionId: "12345678" + } + } + } + } + }) + ); + + await client.send( + new PutCommand({ + TableName: process.env.DB_TABLE, + Item: { + PK: "SERVICE_MANIFEST#core#api", + SK: "default", + GSI1_PK: "SERVICE_MANIFESTS", + GSI1_SK: "core#core", + data: { + name: "core", + manifest: { + bucket: { + name: "bucket" + } + } + } + } + }) + ); + + // Assert + const manifest = await ServiceDiscovery.load(); + expect(manifest).toEqual({ + core: { + bucket: { + name: "bucket" + } + }, + api: { + cloudfront: { + distributionId: "12345678" + } + } + }); + }); +}); diff --git a/packages/api/__tests__/setup/setupAfterEnv.js b/packages/api/__tests__/setup/setupAfterEnv.js new file mode 100644 index 00000000000..0ab4a1796fe --- /dev/null +++ b/packages/api/__tests__/setup/setupAfterEnv.js @@ -0,0 +1,7 @@ +/** + * NOTE: this file will be auto-detected by jest.config.base.js in the root of the repo! + */ +const path = require("path"); +const { setupDynalite } = require("@webiny/project-utils/testing/dynalite"); + +setupDynalite(path.resolve(__dirname, "../../")); diff --git a/packages/api/jest-dynalite-config.js b/packages/api/jest-dynalite-config.js new file mode 100644 index 00000000000..dd245ea811f --- /dev/null +++ b/packages/api/jest-dynalite-config.js @@ -0,0 +1,2 @@ +const { createDynaliteTables } = require("../../jest.config.base"); +module.exports = createDynaliteTables(); diff --git a/packages/api/jest.config.js b/packages/api/jest.setup.js similarity index 100% rename from packages/api/jest.config.js rename to packages/api/jest.setup.js diff --git a/packages/api/package.json b/packages/api/package.json index 9e18ee3a34a..56184e73aef 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,7 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.22.6", + "@webiny/aws-sdk": "0.0.0", "@webiny/plugins": "0.0.0" }, "devDependencies": { diff --git a/packages/api/src/ServiceDiscovery.ts b/packages/api/src/ServiceDiscovery.ts new file mode 100644 index 00000000000..0703fe78b5d --- /dev/null +++ b/packages/api/src/ServiceDiscovery.ts @@ -0,0 +1,77 @@ +import { + getDocumentClient, + DynamoDBDocument, + QueryCommand, + unmarshall +} from "@webiny/aws-sdk/client-dynamodb"; + +interface ServiceManifest { + name: string; + manifest: Manifest; +} + +type Manifest = Record; + +class ServiceManifestLoader { + private client: DynamoDBDocument | undefined; + private manifest: Manifest | undefined = undefined; + + async load() { + if (this.manifest) { + return this.manifest; + } + + const manifests = await this.loadManifests(); + + if (!manifests) { + return undefined; + } + + /** + * Service manifests are already merged by unique names in the database, so we only need to construct + * a final object containing all manifests by name. + */ + this.manifest = manifests.reduce((acc, manifest) => { + return { ...acc, [manifest.name]: manifest.manifest }; + }, {}); + + return this.manifest; + } + + setDocumentClient(client: DynamoDBDocument) { + this.client = client; + } + + private async loadManifests(): Promise { + const client = this.client || getDocumentClient(); + const { Items } = await client.send( + new QueryCommand({ + TableName: String(process.env.DB_TABLE), + IndexName: "GSI1", + KeyConditionExpression: "GSI1_PK = :GSI1_PK AND GSI1_SK > :GSI1_SK", + ExpressionAttributeValues: { + ":GSI1_PK": { S: `SERVICE_MANIFESTS` }, + ":GSI1_SK": { S: " " } + } + }) + ); + + if (!Array.isArray(Items)) { + return undefined; + } + + return Items.map(item => unmarshall(item).data); + } +} + +const serviceManifestLoader = new ServiceManifestLoader(); + +export class ServiceDiscovery { + static setDocumentClient(client: DynamoDBDocument): void { + serviceManifestLoader.setDocumentClient(client); + } + + static async load() { + return serviceManifestLoader.load(); + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 91cd5e5872b..1afaad8b6d7 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,2 +1,3 @@ export * from "~/Context"; +export * from "~/ServiceDiscovery"; export * from "~/plugins/ContextPlugin"; diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json index 0d53f8267e0..bcc9a624b5f 100644 --- a/packages/api/tsconfig.build.json +++ b/packages/api/tsconfig.build.json @@ -1,7 +1,10 @@ { "extends": "../../tsconfig.build.json", "include": ["src"], - "references": [{ "path": "../plugins/tsconfig.build.json" }], + "references": [ + { "path": "../aws-sdk/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" } + ], "compilerOptions": { "rootDir": "./src", "outDir": "./dist", diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 16f1e365509..47c4318ae5a 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src", "__tests__"], - "references": [{ "path": "../plugins" }], + "references": [{ "path": "../aws-sdk" }, { "path": "../plugins" }], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], "outDir": "./dist", @@ -9,6 +9,8 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"] }, diff --git a/packages/app-aco/src/components/Table/components/Table/Columns/ColumnMapper.ts b/packages/app-aco/src/components/Table/components/Table/Columns/ColumnMapper.ts index 6b1ef4cd8b0..0957cad5ad1 100644 --- a/packages/app-aco/src/components/Table/components/Table/Columns/ColumnMapper.ts +++ b/packages/app-aco/src/components/Table/components/Table/Columns/ColumnMapper.ts @@ -31,7 +31,7 @@ export class ColumnMapper { enableHiding: column.hideable, enableResizing: column.resizable, enableSorting: column.sortable, - cell: (row: T) => cellRenderer(row, column.cell) + cell: column.cell ? (row: T) => cellRenderer(row, column.cell) : undefined }; } } diff --git a/packages/app-aco/src/config/AcoConfig.tsx b/packages/app-aco/src/config/AcoConfig.tsx index b71cea3fb00..8b5c94b65be 100644 --- a/packages/app-aco/src/config/AcoConfig.tsx +++ b/packages/app-aco/src/config/AcoConfig.tsx @@ -1,17 +1,20 @@ import { useMemo } from "react"; import { createConfigurableComponent } from "@webiny/react-properties"; +import { Record, RecordConfig } from "./record"; import { Folder, FolderConfig } from "./folder"; import { Table, TableConfig } from "~/config/table"; +export { ActionConfig as RecordActionConfig } from "./record/Action"; export { ActionConfig as FolderActionConfig } from "./folder/Action"; export { ColumnConfig as TableColumnConfig } from "./table/Column"; const base = createConfigurableComponent("AcoConfig"); -export const AcoConfig = Object.assign(base.Config, { Folder, Table }); +export const AcoConfig = Object.assign(base.Config, { Folder, Record, Table }); export const AcoWithConfig = base.WithConfig; interface AcoConfig { + record: RecordConfig; folder: FolderConfig; table: TableConfig; } @@ -20,6 +23,7 @@ export function useAcoConfig() { const config = base.useConfig(); const folder = config.folder || {}; + const record = config.record || {}; const table = config.table || {}; return useMemo( @@ -28,6 +32,10 @@ export function useAcoConfig() { ...folder, actions: [...(folder.actions || [])] }, + record: { + ...record, + actions: [...(record.actions || [])] + }, table: { ...table, columns: [...(table.columns || [])] diff --git a/packages/app-aco/src/config/record/Action.tsx b/packages/app-aco/src/config/record/Action.tsx new file mode 100644 index 00000000000..962aacac534 --- /dev/null +++ b/packages/app-aco/src/config/record/Action.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { OptionsMenuItem, OptionsMenuLink } from "@webiny/app-admin"; +import { Property, useIdGenerator } from "@webiny/react-properties"; + +export interface ActionConfig { + name: string; + element: React.ReactElement; +} + +export interface ActionProps { + name: string; + element?: React.ReactElement; + remove?: boolean; + before?: string; + after?: string; +} + +export const BaseAction = ({ + name, + after = undefined, + before = undefined, + remove = false, + element +}: ActionProps) => { + const getId = useIdGenerator("recordAction"); + + const placeAfter = after !== undefined ? getId(after) : undefined; + const placeBefore = before !== undefined ? getId(before) : undefined; + + return ( + + + + {element ? ( + + ) : null} + + + ); +}; + +export const Action = Object.assign(BaseAction, { + OptionsMenuItem, + OptionsMenuLink +}); diff --git a/packages/app-aco/src/config/record/index.ts b/packages/app-aco/src/config/record/index.ts new file mode 100644 index 00000000000..2f7655fcf77 --- /dev/null +++ b/packages/app-aco/src/config/record/index.ts @@ -0,0 +1,9 @@ +import { Action, ActionConfig } from "./Action"; + +export interface RecordConfig { + actions: ActionConfig[]; +} + +export const Record = { + Action +}; diff --git a/packages/app-admin/src/components/OptionsMenu/OptionsMenuLink.tsx b/packages/app-admin/src/components/OptionsMenu/OptionsMenuLink.tsx new file mode 100644 index 00000000000..51450b179ec --- /dev/null +++ b/packages/app-admin/src/components/OptionsMenu/OptionsMenuLink.tsx @@ -0,0 +1,48 @@ +import React, { HTMLAttributeAnchorTarget } from "react"; +import { Icon } from "@webiny/ui/Icon"; +import { ListItemGraphic } from "@webiny/ui/List"; +import { MenuItem } from "@webiny/ui/Menu"; +import { Link } from "@webiny/react-router"; + +export interface OptionsMenuLinkProps { + disabled?: boolean; + icon: React.ReactElement; + label: string; + to: string; + target?: HTMLAttributeAnchorTarget; + ["data-testid"]?: string; +} + +const MenuLinkItem = (props: Omit) => { + return ( + + + + + {props.label} + + ); +}; + +export const OptionsMenuLink = (props: OptionsMenuLinkProps) => { + if (props.disabled) { + return ( + + ); + } + + return ( + + + + ); +}; diff --git a/packages/app-admin/src/components/OptionsMenu/index.ts b/packages/app-admin/src/components/OptionsMenu/index.ts index be7dd4dcd2d..136cc7d5d5e 100644 --- a/packages/app-admin/src/components/OptionsMenu/index.ts +++ b/packages/app-admin/src/components/OptionsMenu/index.ts @@ -1,3 +1,4 @@ export * from "./OptionsMenu"; export * from "./OptionsMenuItem"; +export * from "./OptionsMenuLink"; export { useOptionsMenuItem } from "./useOptionsMenuItem"; diff --git a/packages/app-admin/src/components/OptionsMenu/useOptionsMenuItem.tsx b/packages/app-admin/src/components/OptionsMenu/useOptionsMenuItem.tsx index c7a0559a14f..b4cd74fc648 100644 --- a/packages/app-admin/src/components/OptionsMenu/useOptionsMenuItem.tsx +++ b/packages/app-admin/src/components/OptionsMenu/useOptionsMenuItem.tsx @@ -1,8 +1,10 @@ import React from "react"; import { OptionsMenuItem } from "./OptionsMenuItem"; +import { OptionsMenuLink } from "./OptionsMenuLink"; export interface OptionsMenuItemProviderContext { OptionsMenuItem: typeof OptionsMenuItem; + OptionsMenuLink: typeof OptionsMenuLink; } const OptionsMenuItemContext = React.createContext( @@ -15,7 +17,7 @@ interface OptionsMenuItemProviderProps { export const OptionsMenuItemProvider = ({ children }: OptionsMenuItemProviderProps) => { return ( - + {children} ); diff --git a/packages/app-admin/src/components/Wcp.tsx b/packages/app-admin/src/components/Wcp.tsx new file mode 100644 index 00000000000..f66670d7e0e --- /dev/null +++ b/packages/app-admin/src/components/Wcp.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { useWcp } from "@webiny/app-wcp"; + +interface CanUsePrivateFilesProps { + children: React.ReactNode; +} + +function CanUsePrivateFiles({ children }: CanUsePrivateFilesProps) { + const wcp = useWcp(); + + return wcp.canUsePrivateFiles() ? <>{children} : null; +} + +export const Wcp = { + CanUsePrivateFiles +}; diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts index 314046086c8..f787f86becc 100644 --- a/packages/app-admin/src/index.ts +++ b/packages/app-admin/src/index.ts @@ -41,6 +41,7 @@ export { SingleImageUploadProps } from "./components/SingleImageUpload"; export { LexicalEditor } from "./components/LexicalEditor/LexicalEditor"; +export { Wcp } from "./components/Wcp"; export { FileManager, FileManagerRenderer } from "./base/ui/FileManager"; export type { diff --git a/packages/app-admin/src/types.ts b/packages/app-admin/src/types.ts index e01bb790705..a23cb25374f 100644 --- a/packages/app-admin/src/types.ts +++ b/packages/app-admin/src/types.ts @@ -99,6 +99,9 @@ export interface FileItem { width?: number; height?: number; }; + accessControl?: { + type: "public" | "private-authenticated"; + }; extensions?: Record; } diff --git a/packages/app-apw/src/index.tsx b/packages/app-apw/src/index.tsx index bef1761c896..a1375ba95ba 100644 --- a/packages/app-apw/src/index.tsx +++ b/packages/app-apw/src/index.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Compose, MenuItemRenderer, Plugins, useWcp } from "@webiny/app-admin"; +import { Components } from "@webiny/app-page-builder"; /** * Plugins for "page builder" */ @@ -11,13 +12,7 @@ import { PublishPageMenuOptionHoc, PageRevisionListItemGraphicHoc } from "./plugins/pageBuilder/PublishPageHocs"; -/** - * TODO: Fix this import so that we can import it from root level maybe - */ -import PagePublishRevision from "@webiny/app-page-builder/admin/plugins/pageDetails/header/publishRevision/PublishRevision"; -import { PublishPageMenuOption } from "@webiny/app-page-builder/admin/plugins/pageDetails/pageRevisions/PublishPageMenuOption"; -import { PublishPageButton } from "@webiny/app-page-builder/pageEditor"; -import { PageRevisionListItemGraphic } from "@webiny/app-page-builder/admin/plugins/pageDetails/pageRevisions/PageRevisionListItemGraphic"; + import { ApwPageBuilderWorkflowScope } from "~/views/publishingWorkflows/components/pageBuilder/ApwPageBuilderWorkflowScope"; /** * Plugins for "Headless CMS" @@ -47,12 +42,21 @@ export const AdvancedPublishingWorkflow = () => { } return ( <> - - - + + + { - const { fields: defaultFields } = useFileModel(); - const { - useWorker, - useButtons, - useDialog: useBulkActionDialog - } = FileManagerViewConfig.Browser.BulkAction; - const worker = useWorker(); - const { updateFile } = useFileManagerView(); - const { canEdit } = useFileManagerApi(); + const { fields: allModelFields } = useFileModel(); + const config = useFileManagerViewConfig(); const { IconButton } = useButtons(); - const { showConfirmationDialog, showResultsDialog } = useBulkActionDialog(); + + const fields = useMemo(() => { + if (!config.fileDetails.fields.find(field => field.name === "tags")) { + return allModelFields.filter(field => field.fieldId !== "tags"); + } + + return allModelFields; + }, [config, allModelFields]); + + const worker = useActionEditWorker(fields); const presenter = useMemo(() => { return new ActionEditPresenter(); }, []); useEffect(() => { - presenter.load(defaultFields); - }, [defaultFields]); - - const filesLabel = useMemo(() => { - return getFilesLabel(worker.items.length); - }, [worker.items.length]); - - const canEditAll = useMemo(() => { - return worker.items.every(item => canEdit(item)); - }, [worker.items]); - - const openWorkerDialog = (batch: BatchDTO) => { - showConfirmationDialog({ - title: "Edit files", - message: `You are about to edit ${filesLabel}. Are you sure you want to continue?`, - loadingLabel: `Processing ${filesLabel}`, - execute: async () => { - await worker.processInSeries(async ({ item, report }) => { - try { - const extensions = defaultFields.find( - field => field.fieldId === "extensions" - ); - - const extensionsData = GraphQLInputMapper.toGraphQLExtensions( - item.extensions, - batch - ); - - const output = omit(item, ["id", "createdBy", "createdOn", "src"]); - - const fileData = { - ...output, - extensions: prepareFormData( - extensionsData, - extensions?.settings?.fields || [] - ) - }; - - await updateFile(item.id, fileData); - - report.success({ - title: `${item.name}`, - message: "File successfully edited." - }); - } catch (e) { - report.error({ - title: `${item.name}`, - message: e.message - }); - } - }); - - worker.resetItems(); - - showResultsDialog({ - results: worker.results, - title: "Edit files", - message: "Finished editing files! See full report below:" - }); - } - }); - }; + presenter.load(fields); + }, [fields]); const onBatchEditorSubmit = useCallback( (batch: BatchDTO) => { presenter.closeEditor(); - openWorkerDialog(batch); + worker.openWorkerDialog(batch); }, - [openWorkerDialog] + [worker.openWorkerDialog] ); if (!presenter.vm.show) { return null; } - if (!canEditAll) { + if (!worker.canEditAll) { console.log("You don't have permissions to edit files."); return null; } @@ -119,7 +58,7 @@ export const ActionEdit = observer(() => { } onAction={() => presenter.openEditor()} - label={`Edit ${filesLabel}`} + label={`Edit ${worker.filesLabel}`} tooltipPlacement={"bottom"} /> { { field: "", operator: "", - value: {} + value: undefined } ] }); @@ -123,7 +123,7 @@ describe("ActionEditPresenter", () => { expect(presenter.vm.fields).toEqual([ { label: "Field 1", - value: "field1", + value: "extensions.field1", operators: [ { label: "Override existing values", @@ -138,7 +138,7 @@ describe("ActionEditPresenter", () => { }, { label: "Field 2", - value: "field2", + value: "extensions.field2", operators: [ { label: "Override existing values", diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/ActionEditPresenter.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/ActionEditPresenter.ts index 0d485e4a282..de0efa0edb5 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/ActionEditPresenter.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/ActionEditPresenter.ts @@ -10,6 +10,10 @@ import { FieldRaw } from "~/components/BulkActions/ActionEdit/domain"; +function isBulkEditableField(field: FieldRaw) { + return field.tags && field.tags.includes("$bulk-edit"); +} + interface IActionEditPresenter { load: (fields: FieldRaw[]) => void; openEditor: () => void; @@ -27,16 +31,41 @@ interface IActionEditPresenter { export class ActionEditPresenter implements IActionEditPresenter { private showEditor = false; private readonly currentBatch: BatchDTO; - private extensionFields: FieldDTO[]; + private fields: FieldDTO[]; constructor() { - this.extensionFields = []; + this.fields = []; this.currentBatch = BatchMapper.toDTO(Batch.createEmpty()); makeAutoObservable(this); } load(fields: FieldRaw[]) { - this.extensionFields = this.getExtensionFields(fields); + this.fields = [...this.getBuiltInFields(fields), ...this.getExtensionFields(fields)]; + } + + get vm() { + return { + show: this.fields.length > 0, + currentBatch: this.currentBatch, + fields: this.fields, + editorVm: this.editorVm + }; + } + + openEditor() { + this.showEditor = true; + } + + closeEditor() { + this.showEditor = false; + } + + private getBuiltInFields(fields: FieldRaw[]) { + const builtInFields = fields + .filter(field => field.fieldId !== "extensions") + .filter(isBulkEditableField); + + return FieldMapper.toDTO(builtInFields.map(field => Field.createFromRaw(field))); } private getExtensionFields(fields: FieldRaw[]) { @@ -46,12 +75,15 @@ export class ActionEditPresenter implements IActionEditPresenter { return []; } - const extensionFields = - extensions.settings.fields.filter( - field => field.tags && field.tags.includes("$bulk-edit") - ) || []; + const extensionFields = extensions.settings.fields.filter(isBulkEditableField) || []; + + const extFields = FieldMapper.toDTO( + extensionFields.map(field => Field.createFromRaw(field)) + ); - return FieldMapper.toDTO(extensionFields.map(field => Field.createFromRaw(field))); + return extFields.map(field => { + return { ...field, value: `extensions.${field.value}` }; + }); } private get editorVm() { @@ -59,21 +91,4 @@ export class ActionEditPresenter implements IActionEditPresenter { isOpen: this.showEditor }; } - - get vm() { - return { - show: this.extensionFields.length > 0, - currentBatch: this.currentBatch, - fields: this.extensionFields, - editorVm: this.editorVm - }; - } - - openEditor() { - this.showEditor = true; - } - - closeEditor() { - this.showEditor = false; - } } diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditor.tsx b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditor.tsx index 23a22f0bee3..cd5b314cc73 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditor.tsx +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditor.tsx @@ -37,7 +37,9 @@ export const BatchEditor = observer((props: BatchEditorProps) => { ref={formRef} data={props.vm.data} onChange={props.onChange} - onSubmit={props.onSubmit} + onSubmit={data => { + console.log("data", data); + }} invalidFields={props.vm.invalidFields} > {() => ( diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialog.tsx b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialog.tsx index 143b3722785..17a4f1f9951 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialog.tsx +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialog.tsx @@ -25,6 +25,8 @@ export const BatchEditorDialog = observer((props: BatchEditorDialogProps) => { return new BatchEditorDialogPresenter(); }, []); + const ref = useRef(null); + useEffect(() => { presenter.load(props.batch, props.fields); }, [props.batch, props.fields]); @@ -34,15 +36,21 @@ export const BatchEditorDialog = observer((props: BatchEditorDialogProps) => { }; const onApply = () => { - presenter.onApply(batch => { - props.onApply(batch); + ref.current?.validate().then(isValid => { + if (isValid) { + presenter.onApply(batch => { + props.onApply(batch); + }); + } }); }; - const ref = useRef(null); - return ( - + {props.vm.isOpen ? ( <> {"Edit items"} diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.test.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.test.ts index 7abdf2ce430..74492af7396 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.test.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.test.ts @@ -12,7 +12,7 @@ describe("BatchEditorDialogPresenter", () => { { field: "", operator: "", - value: {} + value: undefined } ] }; @@ -104,7 +104,7 @@ describe("BatchEditorDialogPresenter", () => { canDelete: false, field: "", operator: "", - value: {}, + value: undefined, fieldOptions: fields, operatorOptions: [], selectedField: undefined @@ -130,7 +130,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [], @@ -148,7 +148,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [], @@ -159,7 +159,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: true, fieldOptions: fields, operatorOptions: [], @@ -177,7 +177,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [], @@ -196,7 +196,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [], @@ -238,7 +238,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "", operator: "", - value: {}, + value: undefined, canDelete: true, fieldOptions: [fields[1]], operatorOptions: [], @@ -266,7 +266,7 @@ describe("BatchEditorDialogPresenter", () => { open: false, field: fields[1].value, operator: OperatorType.REMOVE, - value: {}, + value: undefined, canDelete: true, fieldOptions: [fields[1]], operatorOptions: [operators[0], operators[1]], @@ -339,7 +339,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: "new-field", operator: "", - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [], @@ -360,7 +360,7 @@ describe("BatchEditorDialogPresenter", () => { open: true, field: fields[0].value, operator: "", // empty value -> this should trigger the error - value: {}, + value: undefined, canDelete: false, fieldOptions: fields, operatorOptions: [operators[0], operators[1]], diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.tsx b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.tsx index f1b2abb5765..3e7bf89ba4e 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.tsx +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/BatchEditorDialogPresenter.tsx @@ -67,62 +67,6 @@ export class BatchEditorDialogPresenter implements IBatchEditorDialogPresenter { }; } - private getOperations = () => { - return ( - this.batch?.operations.map((operation: OperationDTO, operationIndex) => { - const fieldOptions = this.getFieldOptions(operation.field); - const selectedField = fieldOptions.find(field => field.value === operation.field); - const operatorOptions = selectedField?.operators || []; - - return { - title: - this.getOperationTitle(operation.field, operation.operator) ?? - `Operation #${operationIndex + 1}`, - open: true, - field: operation.field, - operator: operation.operator, - value: operation.value, - canDelete: operationIndex !== 0, - fieldOptions, - selectedField, - operatorOptions - }; - }) || [] - ); - }; - - private getOperationTitle(inputField?: string, inputOperation?: string) { - if (!inputField || !inputOperation) { - return undefined; - } - - const field = this.fields.find(field => field.value === inputField); - - if (!field) { - return undefined; - } - - const operator = field.operators.find(operator => operator.value === inputOperation); - - if (!operator) { - return undefined; - } - - return `${operator.label} for field "${field.label}"`; - } - - private getFieldOptions(currentFieldId = "") { - if (!this.batch) { - return []; - } - - const existings = this.batch.operations - .filter(operation => operation.field !== currentFieldId) - .map(operation => operation.field); - - return this.fields.filter(field => !existings.includes(field.value)); - } - addOperation(): void { if (!this.batch) { return; @@ -131,7 +75,7 @@ export class BatchEditorDialogPresenter implements IBatchEditorDialogPresenter { this.batch.operations.push({ field: "", operator: "", - value: {} + value: undefined }); } @@ -160,7 +104,7 @@ export class BatchEditorDialogPresenter implements IBatchEditorDialogPresenter { { field: data, operator: "", - value: {} + value: undefined }, ...this.batch.operations.slice(batchIndex + 1) ]; @@ -204,6 +148,62 @@ export class BatchEditorDialogPresenter implements IBatchEditorDialogPresenter { } } + private getOperations = () => { + return ( + this.batch?.operations.map((operation: OperationDTO, operationIndex) => { + const fieldOptions = this.getFieldOptions(operation.field); + const selectedField = fieldOptions.find(field => field.value === operation.field); + const operatorOptions = selectedField?.operators || []; + + return { + title: + this.getOperationTitle(operation.field, operation.operator) ?? + `Operation #${operationIndex + 1}`, + open: true, + field: operation.field, + operator: operation.operator, + value: operation.value, + canDelete: operationIndex !== 0, + fieldOptions, + selectedField, + operatorOptions + }; + }) || [] + ); + }; + + private getOperationTitle(inputField?: string, inputOperation?: string) { + if (!inputField || !inputOperation) { + return undefined; + } + + const field = this.fields.find(field => field.value === inputField); + + if (!field) { + return undefined; + } + + const operator = field.operators.find(operator => operator.value === inputOperation); + + if (!operator) { + return undefined; + } + + return `${operator.label} for field "${field.label}"`; + } + + private getFieldOptions(currentFieldId = "") { + if (!this.batch) { + return []; + } + + const existings = this.batch.operations + .filter(operation => operation.field !== currentFieldId) + .map(operation => operation.field); + + return this.fields.filter(field => !existings.includes(field.value)); + } + private validateBatch(data: BatchDTO) { this.formWasSubmitted = true; const validation = Batch.validate(data); diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/FieldRenderer.tsx b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/FieldRenderer.tsx index 473fb2c3341..74919c0cec1 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/FieldRenderer.tsx +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/FieldRenderer.tsx @@ -1,49 +1,44 @@ import React from "react"; - import { RenderFieldElement, ModelProvider } from "@webiny/app-headless-cms"; -import { Bind, Form, useBind } from "@webiny/form"; - +import { Bind, BindPrefix } from "@webiny/form"; +import { Cell } from "@webiny/ui/Grid"; import { FieldDTO, OperatorType } from "~/components/BulkActions/ActionEdit/domain"; - import { useFileModel } from "~/hooks/useFileModel"; -import { Cell } from "@webiny/ui/Grid"; +import { useFileManagerViewConfig } from "~/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig"; export interface FieldRendererProps { name: string; operator: string; - field?: FieldDTO; + field: FieldDTO; } export const FieldRenderer = (props: FieldRendererProps) => { const fileModel = useFileModel(); - - const { onChange } = useBind({ - name: props.name - }); - - if (!props.field) { - return null; - } + const { browser } = useFileManagerViewConfig(); if (!props.operator || props.operator === OperatorType.REMOVE) { return null; } + const customFieldRenderer = browser.bulkEditFields.find( + field => field.name === props.field.value + ); + + const renderer = customFieldRenderer ? ( + {customFieldRenderer.element} + ) : ( + + + + ); + return ( - -
- {() => { - return ( - - ); - }} - -
+ {renderer}
); }; diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/Operation.tsx b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/Operation.tsx index 5eb64235829..d50b99fdb69 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/Operation.tsx +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/BatchEditorDialog/Operation.tsx @@ -46,11 +46,13 @@ export const Operation = observer((props: OperationProps) => { )} - + {props.operation.selectedField ? ( + + ) : null} ); }); diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.test.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.test.ts index 8c355c9280f..ad53ec34603 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.test.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.test.ts @@ -2,69 +2,309 @@ import { GraphQLInputMapper } from "./GraphQLInputMapper"; import { BatchDTO, OperatorType } from "~/components/BulkActions/ActionEdit/domain"; import { FileItem } from "@webiny/app-admin/types"; +const fileMock: FileItem = { + id: "12345678", + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + location: { + folderId: "root" + }, + createdBy: { + id: "123", + displayName: "123" + }, + src: `https://demo.website.com/files/12345678/filenameA.png`, + key: `12345678/filenameA.png`, + name: "filenameA.png", + size: 123456, + type: "image/png", + tags: ["sketch", "file-a", "webiny"], + aliases: [] +}; + describe("GraphQLInputMapper", () => { it("should return a GraphQL formatted output based on the received BatchDTO and previous data", () => { - const data: FileItem["extensions"] = { - field1: "old-field1", - field2: "old-field2", - field3: ["old-field3"] + const data: FileItem = { + ...fileMock, + extensions: { + field1: "old-field1", + field2: "old-field2", + field3: ["old-field3"] + } }; const batch: BatchDTO = { operations: [ { - field: "field1", + field: "accessControl", + operator: OperatorType.OVERRIDE, + value: { + accessControl: { + type: "private" + } + } + }, + { + field: "extensions.field1", operator: OperatorType.OVERRIDE, value: { - field1: "new-field1" + extensions: { + field1: "new-field1" + } } }, { - field: "field2", + field: "extensions.field2", operator: OperatorType.REMOVE }, { - field: "field3", + field: "extensions.field3", operator: OperatorType.APPEND, value: { - field3: ["new-field3-1", "new-field3-2"] + extensions: { + field3: ["new-field3-1", "new-field3-2"] + } } } ] }; - const output = GraphQLInputMapper.toGraphQLExtensions(data, batch); + const output = GraphQLInputMapper.applyOperations(data, batch); expect(output).toEqual({ - field1: "new-field1", - field2: null, - field3: ["old-field3", "new-field3-1", "new-field3-2"] + ...data, + accessControl: { + type: "private" + }, + extensions: { + field1: "new-field1", + field2: null, + field3: ["old-field3", "new-field3-1", "new-field3-2"] + } }); }); it("should not override data for fields not defined in the batch", () => { - const data: FileItem["extensions"] = { - field1: "old-field1", - field2: "old-field2" + const data: FileItem = { + ...fileMock, + accessControl: { + type: "public" + }, + extensions: { + field1: "old-field1", + field2: "old-field2" + } }; const batch: BatchDTO = { operations: [ { - field: "field1", + field: "extensions.field1", + operator: OperatorType.OVERRIDE, + value: { + extensions: { + field1: "new-field1" + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: "new-field1", + field2: "old-field2" + } + }); + }); + + it("should not OVERRIDE data in case of nullish value", () => { + const data: FileItem = { + ...fileMock, + extensions: { + field1: "old-field-1" + } + }; + + const batch: BatchDTO = { + operations: [ + { + field: "extensions.field1", operator: OperatorType.OVERRIDE, value: { - field1: "new-field1" + extensions: { + field1: null + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: "old-field-1" + } + }); + }); + + it("should not APPEND data in case of nullish value", () => { + const data: FileItem = { + ...fileMock, + extensions: { + field1: "old-field-1" + } + }; + + const batch: BatchDTO = { + operations: [ + { + field: "extensions.field1", + operator: OperatorType.APPEND, + value: { + extensions: { + field1: null + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: "old-field-1" + } + }); + }); + + it("should not APPEND data in case of non-array value", () => { + const data: FileItem = { + ...fileMock, + extensions: { + field1: "old-field-1" + } + }; + + const batch: BatchDTO = { + operations: [ + { + field: "extensions.field1", + operator: OperatorType.APPEND, + value: { + extensions: { + field1: "any-string" + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: "old-field-1" + } + }); + }); + + it("should APPEND new data to non existing envelope", () => { + const data: FileItem = { ...fileMock }; + + const batch: BatchDTO = { + operations: [ + { + field: "extensions.field1", + operator: OperatorType.APPEND, + value: { + extensions: { + field1: ["new-field1-1", "new-field1-2"] + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: ["new-field1-1", "new-field1-2"] + } + }); + }); + + it("should APPEND new data for fields with nullish value", () => { + const data: FileItem = { + ...fileMock, + extensions: { + field1: null + } + }; + + const batch: BatchDTO = { + operations: [ + { + field: "extensions.field1", + operator: OperatorType.APPEND, + value: { + extensions: { + field1: ["new-field1-1", "new-field1-2"] + } + } + } + ] + }; + + const output = GraphQLInputMapper.applyOperations(data, batch); + + expect(output).toEqual({ + ...data, + extensions: { + field1: ["new-field1-1", "new-field1-2"] + } + }); + }); + + it("should return existing data in case of invalid operation", () => { + const data: FileItem = { + ...fileMock, + extensions: { + field1: "old-field1" + } + }; + + const batch: BatchDTO = { + operations: [ + { + field: "field1", + operator: "ANY-OPERATION", + value: { + extensions: { + field1: "new-field1" + } } } ] }; - const output = GraphQLInputMapper.toGraphQLExtensions(data, batch); + const output = GraphQLInputMapper.applyOperations(data, batch); expect(output).toEqual({ - field1: "new-field1", - field2: "old-field2" + ...data, + extensions: { + field1: "old-field1" + } }); }); }); diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.ts index c0066e6987b..8c13f38984f 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/GraphQLInputMapper.ts @@ -1,32 +1,34 @@ +import set from "lodash/set"; +import get from "lodash/get"; import { FileItem } from "@webiny/app-admin/types"; import { BatchDTO, OperatorType } from "~/components/BulkActions/ActionEdit/domain"; export class GraphQLInputMapper { - static toGraphQLExtensions(data: FileItem["extensions"], batch: BatchDTO) { + static applyOperations(data: FileItem, batch: BatchDTO) { const update = { ...data }; batch.operations.forEach(operation => { const { field, operator, value } = operation; + const fieldValue = get(value, field); switch (operator) { case OperatorType.OVERRIDE: - if (!value || !value[field]) { + if (!fieldValue) { return; } - update[field] = value[field]; + set(update, field, fieldValue); break; case OperatorType.REMOVE: - update[field] = null; + set(update, field, null); break; case OperatorType.APPEND: - if (!value || !value[field] || !Array.isArray(value[field])) { + if (!value || !fieldValue || !Array.isArray(fieldValue)) { return; } - if (data && data[field]) { - update[field] = [...data[field], ...value[field]]; - } + const oldData = (data && get(data, field)) ?? []; + set(update, field, [...oldData, ...fieldValue]); break; default: diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/BatchMapper.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/BatchMapper.ts index f6626e36bf6..c87983e9f76 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/BatchMapper.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/BatchMapper.ts @@ -6,7 +6,7 @@ export class BatchMapper { operations: input.operations.map(operation => ({ operator: operation.operator || "", field: operation.field || "", - value: operation.value || {} + value: operation.value || undefined })) }; } diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/Field.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/Field.ts index 6ec2deeac87..af2e90bd950 100644 --- a/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/Field.ts +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/domain/Field.ts @@ -23,7 +23,7 @@ export class Field { static createFromRaw(field: FieldRaw) { const label = field.label; - const value = field.id; + const value = field.fieldId; const operators = Operator.createFromField(field); return new Field(label, value, operators, field); } diff --git a/packages/app-file-manager/src/components/BulkActions/ActionEdit/useActionEditWorker.ts b/packages/app-file-manager/src/components/BulkActions/ActionEdit/useActionEditWorker.ts new file mode 100644 index 00000000000..38616ee11c2 --- /dev/null +++ b/packages/app-file-manager/src/components/BulkActions/ActionEdit/useActionEditWorker.ts @@ -0,0 +1,77 @@ +import { useMemo } from "react"; +import omit from "lodash/omit"; +import { FileItem } from "@webiny/app-admin/types"; +import { prepareFormData } from "@webiny/app-headless-cms-common"; +import { CmsModelField } from "@webiny/app-headless-cms-common/types"; +import { BatchDTO } from "~/components/BulkActions/ActionEdit/domain"; +import { GraphQLInputMapper } from "~/components/BulkActions/ActionEdit/GraphQLInputMapper"; +import { useFileManagerView } from "~/modules/FileManagerRenderer/FileManagerViewProvider"; +import { getFilesLabel } from "~/components/BulkActions"; +import { useFileManagerApi } from "~/modules/FileManagerApiProvider/FileManagerApiContext"; +import { FileManagerViewConfig } from "~/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig"; + +const { useWorker, useDialog: useBulkActionDialog } = FileManagerViewConfig.Browser.BulkAction; + +export function useActionEditWorker(fields: CmsModelField[]) { + const { updateFile } = useFileManagerView(); + const { showConfirmationDialog, showResultsDialog } = useBulkActionDialog(); + const worker = useWorker(); + const { canEdit } = useFileManagerApi(); + + const filesLabel = useMemo(() => { + return getFilesLabel(worker.items.length); + }, [worker.items.length]); + + const canEditAll = useMemo(() => { + return worker.items.every(item => canEdit(item)); + }, [worker.items]); + + const openWorkerDialog = (batch: BatchDTO) => { + showConfirmationDialog({ + title: "Edit files", + message: `You are about to edit ${filesLabel}. Are you sure you want to continue?`, + loadingLabel: `Processing ${filesLabel}`, + execute: async () => { + await worker.processInSeries(async ({ item, report }) => { + try { + const modifiedFileData = GraphQLInputMapper.applyOperations( + item, + batch + ) as FileItem; + + const output = omit(modifiedFileData, [ + "id", + "createdBy", + "createdOn", + "src" + ]); + + const fileData = prepareFormData(output, fields); + + await updateFile(item.id, fileData); + + report.success({ + title: `${item.name}`, + message: "File successfully edited." + }); + } catch (e) { + report.error({ + title: `${item.name}`, + message: e.message + }); + } + }); + + worker.resetItems(); + + showResultsDialog({ + results: worker.results, + title: "Edit files", + message: "Finished editing files! See full report below:" + }); + } + }); + }; + + return { filesLabel, canEditAll, openWorkerDialog }; +} 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 57735f12094..a8439b78295 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, Form, useBind } from "@webiny/form"; +import { Bind, BindComponentProps } from "@webiny/form"; const HideEmptyCells = styled.div` .mdc-layout-grid__cell:empty { @@ -16,6 +16,13 @@ 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"); @@ -32,29 +39,18 @@ export const Extensions = ({ model }: ExtensionsProps) => { layout.push(...fields.map(field => [field.fieldId])); } - const { value, onChange } = useBind({ - name: "extensions" - }); - return ( -
- {() => ( - - - - )} -
+ + +
); diff --git a/packages/app-file-manager/src/components/Table/Actions/CopyFile.tsx b/packages/app-file-manager/src/components/Table/Actions/CopyFile.tsx new file mode 100644 index 00000000000..c8a3a3d2891 --- /dev/null +++ b/packages/app-file-manager/src/components/Table/Actions/CopyFile.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ReactComponent as Copy } from "@material-design-icons/svg/outlined/content_copy.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useCopyFile } from "~/hooks/useCopyFile"; +import { useFile } from "~/hooks/useFile"; + +export const CopyFile = () => { + const { file } = useFile(); + const { copyFileUrl } = useCopyFile({ file }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + return ( + } + label={"Copy"} + onAction={copyFileUrl} + data-testid={"aco.actions.file.copy"} + /> + ); +}; diff --git a/packages/app-file-manager/src/components/Table/Actions/DeleteFile.tsx b/packages/app-file-manager/src/components/Table/Actions/DeleteFile.tsx new file mode 100644 index 00000000000..ebb47e4bb18 --- /dev/null +++ b/packages/app-file-manager/src/components/Table/Actions/DeleteFile.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useFileManagerApi } from "~/modules/FileManagerApiProvider/FileManagerApiContext"; +import { useDeleteFile } from "~/hooks/useDeleteFile"; +import { useFile } from "~/hooks/useFile"; + +export const DeleteFile = () => { + const { file } = useFile(); + const { canDelete } = useFileManagerApi(); + const { openDialogDeleteFile } = useDeleteFile({ + file + }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + if (!canDelete(file)) { + return null; + } + + return ( + } + label={"Delete"} + onAction={openDialogDeleteFile} + data-testid={"aco.actions.file.delete"} + /> + ); +}; diff --git a/packages/app-file-manager/src/components/Table/Actions/EditFile.tsx b/packages/app-file-manager/src/components/Table/Actions/EditFile.tsx new file mode 100644 index 00000000000..a044818e310 --- /dev/null +++ b/packages/app-file-manager/src/components/Table/Actions/EditFile.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useFileManagerView } from "~/modules/FileManagerRenderer/FileManagerViewProvider"; +import { useFile } from "~/hooks/useFile"; + +export const EditFile = () => { + const { file } = useFile(); + const { showFileDetails } = useFileManagerView(); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + return ( + } + label={"Edit"} + onAction={() => showFileDetails(file.id)} + data-testid={"aco.actions.file.edit"} + /> + ); +}; diff --git a/packages/app-file-manager/src/components/Table/Actions/MoveFile.tsx b/packages/app-file-manager/src/components/Table/Actions/MoveFile.tsx new file mode 100644 index 00000000000..1ca314fb261 --- /dev/null +++ b/packages/app-file-manager/src/components/Table/Actions/MoveFile.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useFile } from "~/hooks/useFile"; +import { useMoveFileToFolder } from "~/hooks/useMoveFileToFolder"; + +export const MoveFile = () => { + const { file } = useFile(); + const moveFileToFolder = useMoveFileToFolder(file); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + return ( + } + label={"Move"} + onAction={moveFileToFolder} + data-testid={"aco.actions.file.move"} + /> + ); +}; diff --git a/packages/app-file-manager/src/components/Table/Actions/index.ts b/packages/app-file-manager/src/components/Table/Actions/index.ts new file mode 100644 index 00000000000..19b90b6b867 --- /dev/null +++ b/packages/app-file-manager/src/components/Table/Actions/index.ts @@ -0,0 +1,4 @@ +export * from "./CopyFile"; +export * from "./DeleteFile"; +export * from "./EditFile"; +export * from "./MoveFile"; diff --git a/packages/app-file-manager/src/components/Table/Cells/CellActions.tsx b/packages/app-file-manager/src/components/Table/Cells/CellActions.tsx index 900b675df44..f2713e6a074 100644 --- a/packages/app-file-manager/src/components/Table/Cells/CellActions.tsx +++ b/packages/app-file-manager/src/components/Table/Cells/CellActions.tsx @@ -1,24 +1,13 @@ import React from "react"; - import { OptionsMenu } from "@webiny/app-admin"; -import { IconButton } from "@webiny/ui/Button"; -import { ReactComponent as MoreIcon } from "@material-design-icons/svg/filled/more_vert.svg"; -import { Menu } from "@webiny/ui/Menu"; -import { RecordActionCopy } from "../RecordActionCopy"; -import { RecordActionDelete } from "../RecordActionDelete"; -import { RecordActionEdit } from "../RecordActionEdit"; -import { RecordActionMove } from "../RecordActionMove"; -import { menuStyles } from "./Cells.styled"; import { FolderProvider, useAcoConfig } from "@webiny/app-aco"; -import { useFileManagerView } from "~/modules/FileManagerRenderer/FileManagerViewProvider"; import { FileProvider } from "~/contexts/FileProvider"; import { FileManagerViewConfig } from "~/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig"; export const CellActions = () => { const { useTableRow, isFolderRow } = FileManagerViewConfig.Browser.Table.Column; const { row } = useTableRow(); - const { showFileDetails } = useFileManagerView(); - const { folder: folderConfig } = useAcoConfig(); + const { folder: folderConfig, record: recordConfig } = useAcoConfig(); if (isFolderRow(row)) { // If the user cannot manage folder structure, no need to show the menu. @@ -38,12 +27,10 @@ export const CellActions = () => { return ( - } />}> - - - - - + ); }; diff --git a/packages/app-file-manager/src/components/Table/Cells/Cells.styled.tsx b/packages/app-file-manager/src/components/Table/Cells/Cells.styled.tsx index 10eab5ebe23..8b472745de9 100644 --- a/packages/app-file-manager/src/components/Table/Cells/Cells.styled.tsx +++ b/packages/app-file-manager/src/components/Table/Cells/Cells.styled.tsx @@ -1,5 +1,4 @@ import styled from "@emotion/styled"; -import { css } from "emotion"; import { Typography } from "@webiny/ui/Typography"; export const RowTitle = styled("div")` @@ -18,7 +17,3 @@ export const RowText = styled(Typography)` overflow: hidden; text-overflow: ellipsis; `; - -export const menuStyles = css(` - width: 200px; -`); diff --git a/packages/app-file-manager/src/components/Table/FolderActionDelete.tsx b/packages/app-file-manager/src/components/Table/FolderActionDelete.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/app-file-manager/src/components/Table/FolderActionEdit.tsx b/packages/app-file-manager/src/components/Table/FolderActionEdit.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/app-file-manager/src/components/Table/FolderActionManagePermissions.tsx b/packages/app-file-manager/src/components/Table/FolderActionManagePermissions.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/app-file-manager/src/components/Table/Name.tsx b/packages/app-file-manager/src/components/Table/Name.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/app-file-manager/src/components/Table/RecordActionCopy.tsx b/packages/app-file-manager/src/components/Table/RecordActionCopy.tsx deleted file mode 100644 index 9664502ee86..00000000000 --- a/packages/app-file-manager/src/components/Table/RecordActionCopy.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; - -import { ReactComponent as Copy } from "@material-design-icons/svg/outlined/content_copy.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { useCopyFile } from "~/hooks/useCopyFile"; -import { SearchRecordItem } from "@webiny/app-aco/types"; -import { FileItem } from "@webiny/app-admin/types"; - -import { ListItemGraphic } from "./styled"; - -const t = i18n.ns("app-admin/file-manager/components/table/record-action-copy"); - -interface RecordActionCopyProps { - record: SearchRecordItem["data"]; -} -export const RecordActionCopy = ({ record }: RecordActionCopyProps) => { - const { copyFileUrl } = useCopyFile({ file: record }); - return ( - - - } /> - - {t`Copy`} - - ); -}; diff --git a/packages/app-file-manager/src/components/Table/RecordActionDelete.tsx b/packages/app-file-manager/src/components/Table/RecordActionDelete.tsx deleted file mode 100644 index 1cd905f9e03..00000000000 --- a/packages/app-file-manager/src/components/Table/RecordActionDelete.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; -import { FileItem } from "@webiny/app-admin/types"; -import { SearchRecordItem } from "@webiny/app-aco/types"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { useFileManagerApi } from "~/modules/FileManagerApiProvider/FileManagerApiContext"; -import { useDeleteFile } from "~/hooks/useDeleteFile"; - -import { ListItemGraphic } from "./styled"; - -interface RecordActionDeleteProps { - record: SearchRecordItem["data"]; -} - -export const RecordActionDelete = ({ record }: RecordActionDeleteProps) => { - const { canDelete } = useFileManagerApi(); - const { openDialogDeleteFile } = useDeleteFile({ - file: record - }); - - if (!canDelete(record)) { - return null; - } - - return ( - - - } /> - - Delete - - ); -}; diff --git a/packages/app-file-manager/src/components/Table/RecordActionEdit.tsx b/packages/app-file-manager/src/components/Table/RecordActionEdit.tsx deleted file mode 100644 index b213f94a5fe..00000000000 --- a/packages/app-file-manager/src/components/Table/RecordActionEdit.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; - -import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; - -import { ListItemGraphic } from "./styled"; - -const t = i18n.ns("app-admin/file-manager/components/table/record-action-edit"); - -interface RecordActionEditProps { - id: string; - onClick: (id: string) => void; -} - -export const RecordActionEdit = ({ id, onClick }: RecordActionEditProps) => { - return ( - onClick(id)}> - - } /> - - {t`Edit`} - - ); -}; diff --git a/packages/app-file-manager/src/components/Table/RecordActionMove.tsx b/packages/app-file-manager/src/components/Table/RecordActionMove.tsx deleted file mode 100644 index e63379d89a6..00000000000 --- a/packages/app-file-manager/src/components/Table/RecordActionMove.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { ListItemGraphic } from "./styled"; -import { useFile, useMoveFileToFolder } from "~/index"; - -const t = i18n.ns("app-admin/file-manager/components/table/record-action-move"); - -export const RecordActionMove = () => { - const { file } = useFile(); - const moveFileToFolder = useMoveFileToFolder(file); - - return ( - - - } /> - - {t`Move`} - - ); -}; diff --git a/packages/app-file-manager/src/components/Table/index.tsx b/packages/app-file-manager/src/components/Table/index.tsx index a705c077474..75cd35bda14 100644 --- a/packages/app-file-manager/src/components/Table/index.tsx +++ b/packages/app-file-manager/src/components/Table/index.tsx @@ -1,2 +1,3 @@ +export * from "./Actions"; export * from "./Cells"; export * from "./Table"; diff --git a/packages/app-file-manager/src/components/Table/styled.tsx b/packages/app-file-manager/src/components/Table/styled.tsx deleted file mode 100644 index 1aa113be6b4..00000000000 --- a/packages/app-file-manager/src/components/Table/styled.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import styled from "@emotion/styled"; -import { css } from "emotion"; -import { ListItemGraphic as ListItemGraphicBase } from "@webiny/ui/List"; - -export const ListItemGraphic = styled(ListItemGraphicBase)` - margin-right: 25px; -`; - -export const menuStyles = css(` - width: 200px; -`); diff --git a/packages/app-file-manager/src/components/fields/AccessControl.tsx b/packages/app-file-manager/src/components/fields/AccessControl.tsx new file mode 100644 index 00000000000..78154d64199 --- /dev/null +++ b/packages/app-file-manager/src/components/fields/AccessControl.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { validation } from "@webiny/validation"; +import { Select } from "@webiny/ui/Select"; +import { useBind } from "@webiny/form"; +import { useFileManagerApi } from "~/index"; +import { useAccessControlField } from "./useAccessControlField"; +import { useFileOrUndefined } from "~/components/fields/useFileOrUndefined"; + +interface AccessControlProps { + defaultValue?: string; + placeholder?: string; +} + +export const AccessControl = ({ defaultValue, placeholder }: AccessControlProps) => { + const { file } = useFileOrUndefined(); + const { canEdit } = useFileManagerApi(); + const accessControlField = useAccessControlField(); + + /** + * In reality, this condition will never happen, so we don't need to worry about hooks used further below. + */ + if (!accessControlField) { + return null; + } + + const { options } = accessControlField; + + const bind = useBind({ + name: "accessControl.type", + validators: [validation.create("required")], + defaultValue, + beforeChange(value, cb) { + cb(value === "" ? undefined : value); + } + }); + + return ( + - + ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx index 496e84a101f..c269b6dbd2a 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RuleActionSelect.tsx @@ -1,6 +1,7 @@ -import React from "react"; +import React, { useCallback } from "react"; import { Select } from "@webiny/ui/Select"; import styled from "@emotion/styled"; +import { FbFormRule } from "~/types"; const RuleAction = styled("div")` display: flex; @@ -21,25 +22,35 @@ const RuleAction = styled("div")` `; const ActionSelect = styled(Select)` - margin-left: 35px; margin-right: 15px; - width: 250px; `; interface Props { - value: string; - onChange: (value: string) => void; + rule: FbFormRule; + onChange: (params: FbFormRule) => void; } -export const RuleActionSelect: React.FC = ({ value, onChange }) => { +export const RuleActionSelect = ({ rule, onChange }: Props) => { + const onChangeAction = useCallback( + (value: string) => { + return onChange({ + ...rule, + action: { + type: "", + value + } + }); + }, + [rule.action.value, onChange] + ); + return ( - Then diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx index 4883cb43891..b8bc7671884 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesConditions.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React, { useCallback } from "react"; +import { mdbid } from "@webiny/utils"; import styled from "@emotion/styled"; import { Select } from "@webiny/ui/Select"; @@ -7,6 +8,9 @@ import { IconButton } from "@webiny/ui/Button"; import { fieldConditionOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; import { renderConditionValueController } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/renderConditionValueController"; +import { AddConditionButton } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; import cloneDeep from "lodash/cloneDeep"; @@ -15,79 +19,118 @@ import { FbFormModelField, FbFormCondition, FbFormRule } from "~/types"; const SelectFieldWrapper = styled.div` display: flex; + justify-content: space-between; + align-items: center; margin: 15px 0; & > span { font-size: 22px; } `; -const CondtionsWrapper = styled.div` - display: flex; - margin-left: 20px; +const FieldSelect = styled(Select)` + flex-basis: 35%; `; const SelectCondition = styled(Select)` - margin-right: 15px; - margin-left: 63px; - width: 250px; + flex-basis: 20%; `; const ConditionValue = styled.div` - width: 397px; + flex-basis: 35%; `; -const FieldSelect = styled(Select)` - margin-left: 70px; +const ConditionsChain = styled.div` + text-align: center; + font-size: 12px; + margin-top: 10px; `; +interface AddConditionProps { + rule: FbFormRule; + onChange: (params: FbFormRule) => void; +} + +export const AddCondition = ({ rule, onChange }: AddConditionProps) => { + const onAddCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: [ + ...(rule.conditions || []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }); + }, [rule, onChange]); + + return ( + + } /> + + ); +}; + interface Props { rule: FbFormRule; condition: FbFormCondition; fields: (FbFormModelField | null)[]; - rulesValue: Array; + rules: Array; conditionIndex: number; - onChangeRule: (params: FbFormRule) => void; - deleteCondition: () => void; + onChange: (params: FbFormRule) => void; } -export const RuleConditions: React.FC = ({ - rulesValue, +export const RuleConditions = ({ + rules, fields, condition, rule, conditionIndex, - onChangeRule, - deleteCondition -}) => { + onChange +}: Props) => { const fieldType = fields.find(field => field?.fieldId === condition?.fieldName)?.type || ""; - const onChange = (property: string, value: string) => { - const ruleIndex = findIndex(rulesValue, { id: rule.id }); - const rules = cloneDeep(rulesValue); - const conditions = cloneDeep(rules[ruleIndex].conditions || []); + const handleCondition = useCallback( + (property: string, value: string) => { + const ruleIndex = findIndex(rules, { id: rule.id }); + const conditions = cloneDeep(rules[ruleIndex].conditions || []); - conditions[conditionIndex] = { - ...rules[ruleIndex].conditions[conditionIndex], - [property]: value - }; + conditions[conditionIndex] = { + ...rules[ruleIndex].conditions[conditionIndex], + [property]: value + }; - rules[ruleIndex].conditions = conditions; + rules[ruleIndex].conditions = conditions; - onChangeRule({ + return onChange({ + ...rule, + conditions + }); + }, + [condition, rule, onChange] + ); + + const onDeleteCondition = useCallback(() => { + return onChange({ ...rule, - conditions + conditions: (rule.conditions as FbFormCondition[]).filter( + ruleValueCondition => ruleValueCondition.id !== condition.id + ) }); - }; + }, [condition, rule, onChange]); + + const showAddConditionButton = condition.id === rule.conditions[rule.conditions.length - 1].id; return ( <> - If onChange("fieldName", value)} + onChange={value => handleCondition("fieldName", value)} > {fields.map((field: any) => ( ))} - deleteCondition()} />} /> + handleCondition("filterType", val)} + value={condition.filterType} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map(option => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange: handleCondition + })} + + } /> - {!condition.fieldName ? ( - <> - ) : ( - - onChange("filterType", val)} - value={condition.filterType} - > - {fieldConditionOptions - .find(filter => filter.type === fieldType) - ?.options.map(option => ( - - ))} - - {/* This field depends on selected field type */} - - {renderConditionValueController({ - condition, - fields, - handleOnChange: onChange - })} - - - )} + + {rule.conditions.length > 1 ? (rule.matchAll ? "AND" : "OR") : null} + + {showAddConditionButton && } ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx deleted file mode 100644 index ff3a7172e1a..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import React from "react"; - -import { FormRenderPropParams } from "@webiny/form"; -import { Icon } from "@webiny/ui/Icon"; -import { AccordionItem } from "@webiny/ui/Accordion"; -import { Alert } from "@webiny/ui/Alert"; -import { mdbid } from "@webiny/utils"; - -import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; -import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; - -import { RuleConditions } from "./RulesConditions"; -import { conditionChainOptions } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions"; -import { - RulesTabWrapper, - AddRuleButtonWrapper, - RuleButtonDescription, - StyledAccordion, - ConditionSetupWrapper, - AddRuleButton, - AddConditionButton, - ConditionsChainSelect -} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; -import { useFormEditor } from "~/admin/components/FormEditor/Context"; -import { RuleActionSelect } from "./RuleActionSelect"; -import { SelectDefaultBehaviour } from "./DefaultBehaviour"; -import { FbFormModelField, FbFormModel, FbFormRule, FbFormCondition } from "~/types"; -import { getAvailableFields } from "../FormStep/EditFormStepDialog/helpers"; - -const getCondtionFields = (id: string, formData: FbFormModel) => { - const availableFields: Array = []; - - formData.steps.forEach(step => { - const stepLayout = step.layout.flat(2); - - if (stepLayout.includes(id)) { - const fields = getAvailableFields({ step, formData }).filter( - field => field?.type !== "condition-group" - ); - availableFields.push(...fields); - } - }); - - return availableFields; -}; - -interface RulesTabProps { - field: FbFormModelField; - form: FormRenderPropParams; -} - -export const RulesTab = ({ field, form }: RulesTabProps) => { - const { Bind } = form; - - const { data: formData } = useFormEditor(); - const fields = field._id ? getCondtionFields(field._id, formData) : []; - - const areRulesInValid = field?.settings?.rules?.some( - (rule: FbFormRule) => rule.isValid === false - ); - - return ( - - {areRulesInValid && ( - - - At the moment one or more of your rules are broken. To correct the state - please check your rules and ensure they are referencing fields that still - exists and are place inside the current or one of the previous steps. - - - )} - - {({ value: defaultBehaviourValue, onChange: onChangeDefaultBehaviour }) => ( - - )} - - - {({ value: rulesValue, onChange: onChangeRules }) => ( - <> - {rulesValue && - (rulesValue as FbFormRule[]).map((rule, ruleIndex) => ( - - - } - onClick={() => - onChangeRules( - (rulesValue as FbFormRule[]).filter( - rulesValueItem => - rulesValueItem.id !== rule.id - ) - ) - } - /> - - } - > - - {({ value: ruleValue, onChange: onChangeRule }) => ( - <> - {rule.conditions.length === 0 ? ( - - onChangeRule({ - ...ruleValue, - conditions: [ - ...(ruleValue.conditions || - []), - { - fieldName: "", - filterType: "", - filterValue: "", - id: mdbid() - } - ] - }) - } - > - + Add Condition - - ) : ( - <> - {ruleValue.conditions.map( - ( - condition: FbFormCondition, - conditionIndex: number - ) => { - return ( - - - onChangeRule({ - ...ruleValue, - conditions: - ( - ruleValue.conditions as FbFormCondition[] - ).filter( - ruleValueCondition => - ruleValueCondition.id !== - condition.id - ) - }) - } - /> - {condition.id === - ruleValue - .conditions[ - ruleValue - .conditions - .length - 1 - ].id && ( - <> - - onChangeRule( - { - ...ruleValue, - conditions: - [ - ...(ruleValue.conditions || - []), - { - fieldName: - "", - filterType: - "", - filterValue: - "", - id: mdbid() - } - ] - } - ) - } - > - + Add - Condition - - - onChangeRule( - { - ...ruleValue, - chain: val - } - ) - } - > - {conditionChainOptions.map( - chainOption => ( - - ) - )} - - - onChangeRule( - { - ...ruleValue, - action: val - } - ) - } - /> - - )} - - ); - } - )} - - )} - - )} - - - - ))} - - { - onChangeRules([ - ...(rulesValue || []), - { - title: "Rule", - id: mdbid(), - conditions: [], - action: "hide", - isValid: true, - chain: "matchAny" - } - ]); - }} - > - + Add Rule - - - } /> - Click here to learn how field rules work - - - - )} - - - ); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx new file mode 100644 index 00000000000..657e95c19d4 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rule.tsx @@ -0,0 +1,50 @@ +import React from "react"; + +import { BindComponent } from "@webiny/form/types"; + +import { RuleConditions, AddCondition } from "../RulesConditions"; +import { ConditionSetupWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; +import { FbFormRule, FbFormModelField } from "~/types"; +import { RuleActionSelect } from "../RuleActionSelect"; + +interface RuleProps { + bind: BindComponent; + rules: FbFormRule[]; + ruleIndex: number; + fields: (FbFormModelField | null)[]; +} + +interface BindProps { + value: FbFormRule; + onChange: (params: FbFormRule) => void; +} + +export const Rule = ({ ruleIndex, bind: Bind, fields, rules }: RuleProps) => { + return ( + + {({ value: rule, onChange }: BindProps) => ( + <> + {!rule.conditions.length ? ( + + ) : ( + <> + {rule.conditions.map((condition, conditionIndex) => ( + + + + ))} + + + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx new file mode 100644 index 00000000000..0f17266e142 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/Rules.tsx @@ -0,0 +1,137 @@ +import React, { useCallback } from "react"; + +import { mdbid } from "@webiny/utils"; +import { Icon } from "@webiny/ui/Icon"; +import { BindComponent } from "@webiny/form"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Switch } from "@webiny/ui/Switch"; +import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { Rule } from "./Rule"; + +import { + AccordionWithShadow, + StyledAddRuleButton, + AddRuleButtonWrapper, + RuleButtonDescription +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { FbFormModelField, FbFormRule } from "~/types"; + +interface RulesAccordionProps { + children: React.ReactNode; + rules: FbFormRule[]; + rule: FbFormRule; + ruleIndex: number; + onChange: (value: FbFormRule[]) => void; +} + +const RulesAccordion = ({ children, rules, rule, ruleIndex, onChange }: RulesAccordionProps) => { + const onDeleteRule = useCallback(() => { + return onChange(rules.filter(rulesValueItem => rulesValueItem.id !== rule.id)); + }, [rule, onChange]); + + const onChangeConditionChain = useCallback( + (matchAll: boolean) => { + rules[ruleIndex] = { + ...rules[ruleIndex], + matchAll + }; + return onChange([...rules]); + }, + [rule, onChange] + ); + + return ( + + + + } + /> + } onClick={onDeleteRule} /> + + } + > + {children} + + + ); +}; + +interface AddRuleButtonProps { + rules: FbFormRule[]; + onChange: (param: FbFormRule[]) => void; +} + +const AddRuleButton = ({ rules, onChange }: AddRuleButtonProps) => { + const onAddRule = useCallback(() => { + return onChange([ + ...(rules || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: { + type: "", + value: "hide" + }, + isValid: true, + matchAll: false + } + ]); + }, [rules, onChange]); + + return ( + + + Add Rule + + } /> + Click here to learn how field rules work + + + ); +}; + +interface RulesProps { + bind: BindComponent; + fields: (FbFormModelField | null)[]; +} + +interface BindProps { + value: FbFormRule[]; + onChange: (params: FbFormRule[]) => void; +} + +export const Rules = ({ bind: Bind, fields }: RulesProps) => { + return ( + + {({ value: rules, onChange }: BindProps) => ( + <> + {rules.map((rule, ruleIndex) => ( + + + + ))} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx new file mode 100644 index 00000000000..06de4e0dc7e --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditFieldDialog/RulesTab/RulesTab.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { Alert } from "@webiny/ui/Alert"; +import { BindComponent, FormRenderPropParams } from "@webiny/form"; + +import { getAvailableFields } from "~/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/helpers"; +import { useFormEditor } from "~/admin/components/FormEditor/Context"; +import { RulesTabWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { SelectDefaultBehaviour } from "../DefaultBehaviour"; + +import { FbFormRule, FbFormModelField, FbFormModel } from "~/types"; +import { Rules } from "./Rules"; + +interface GetConditionFieldParams { + id: string; + formData: FbFormModel; +} + +const getConditionField = ({ id, formData }: GetConditionFieldParams) => { + const availableFields: Array = []; + + formData.steps.forEach(step => { + const stepLayout = step.layout.flat(2); + + if (stepLayout.includes(id)) { + const fields = getAvailableFields({ step, formData }).filter( + field => field?.type !== "condition-group" + ); + availableFields.push(...fields); + } + }); + + return availableFields; +}; + +interface RuleBrokenAlertProps { + field: FbFormModelField; +} + +const RulesBrokenAlert = ({ field }: RuleBrokenAlertProps) => { + const rulesBroken = field?.settings?.rules?.some((rule: FbFormRule) => rule.isValid === false); + + return rulesBroken !== undefined && rulesBroken === true ? ( + + + At the moment one or more of your rules are broken. To correct the state please + check your rules and ensure they are referencing fields that still exists and are + place inside the current or one of the previous steps. + + + ) : null; +}; + +interface ConditionGroupDefaultBehaviorProps { + bind: BindComponent; +} + +const ConditionGroupDefaultBehavior = ({ bind: Bind }: ConditionGroupDefaultBehaviorProps) => { + return ( + + {({ value, onChange }) => ( + + )} + + ); +}; + +interface RulesTabProps { + field: FbFormModelField; + form: FormRenderPropParams; +} + +export const RulesTab = ({ field, form }: RulesTabProps) => { + const { Bind } = form; + + const { data: formData } = useFormEditor(); + const fields = field._id ? getConditionField({ id: field._id, formData }) : []; + + return ( + + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx index 3562511d481..aac5267c669 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/ConditionGroupField/ConditionGroup.tsx @@ -34,9 +34,9 @@ const ConditionalGroupField = (props: FieldProps) => { const { getField } = useFormEditor(); const getFields = () => { - return (conditionGroupField?.settings?.layout || []).map((row: any) => { + return (conditionGroupField?.settings?.layout || []).map((row: string[]) => { return row - .map((id: any) => { + .map((id: string) => { return getField({ _id: id }); @@ -45,10 +45,8 @@ const ConditionalGroupField = (props: FieldProps) => { }); }; - const fields = getFields().map((fields: any) => - fields - .filter((field: any) => field._id !== conditionGroupField._id) - .filter((field: any) => field.length !== 0) + const fields = getFields().map((fields: FbFormModelField[]) => + fields.filter((field: FbFormModelField) => field._id !== conditionGroupField._id) ) as FbFormModelField[][]; return ( diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx index ae3a869975b..bd2e5c67853 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/EditFormStepDialog.tsx @@ -8,7 +8,7 @@ import { Input } from "@webiny/ui/Input"; import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; import { Tabs, Tab } from "@webiny/ui/Tabs"; -import { RulesTab } from "./RulesTab"; +import { RulesTab } from "./RulesTab/RulesTab"; import { FbFormModel, FbFormStep, FbFormRule } from "~/types"; import { UpdateStepParams } from "~/admin/components/FormEditor/Context/useFormEditorFactory"; @@ -18,7 +18,8 @@ const EditStepDialog = styled(BaseDialog)` color: #fff; font-weight: 600; & .mdc-dialog__surface { - width: 875px; + width: 975px; + max-width: 975px; } `; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx index 2375ceb1568..fa6ce18637c 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleCondition.tsx @@ -11,32 +11,33 @@ import { updateRuleConditions } from "./updateRuleConditions"; const SelectFieldWrapper = styled.div` display: flex; + justify-content: space-between; + align-items: center; margin: 15px 0; & > span { font-size: 22px; } `; -const CondtionsWrapper = styled.div` - display: flex; - margin-left: 20px; +const FieldSelect = styled(Select)` + flex-basis: 35%; `; const SelectCondition = styled(Select)` - margin-right: 15px; - margin-left: 63px; - width: 250px; + flex-basis: 20%; `; const ConditionValue = styled.div` - width: 397px; + flex-basis: 35%; `; -const FieldSelect = styled(Select)` - margin-left: 70px; +const ConditionsChain = styled.div` + text-align: center; + font-size: 12px; + margin-top: 10px; `; -interface Params { +export interface RuleConditionProps { condition: FbFormCondition; rule: FbFormRule; fields: (FbFormModelField | null)[]; @@ -45,7 +46,7 @@ interface Params { onDelete: () => void; } -export const RuleCondition: React.FC = params => { +export const RuleCondition = (params: RuleConditionProps) => { const { condition, rule, fields, conditionIndex, onChange, onDelete } = params; const fieldType = fields.find(field => field?.fieldId === condition.fieldName)?.type || ""; @@ -66,10 +67,9 @@ export const RuleCondition: React.FC = params => { return ( <> - If handleOnChange("fieldName", value)} > @@ -79,36 +79,33 @@ export const RuleCondition: React.FC = params => { ))} + handleOnChange("filterType", value)} + > + {fieldConditionOptions + .find(filter => filter.type === fieldType) + ?.options.map((option, index) => ( + + ))} + + {/* This field depends on selected field type */} + + {renderConditionValueController({ + condition, + fields, + handleOnChange + })} + } /> - {!condition.fieldName ? ( - <> - ) : ( - - handleOnChange("filterType", value)} - > - {fieldConditionOptions - .find(filter => filter.type === fieldType) - ?.options.map((option, index) => ( - - ))} - - {/* This field depends on selected field type */} - - {renderConditionValueController({ - condition, - fields, - handleOnChange - })} - - - )} + + {rule.conditions.length > 1 ? (rule.matchAll ? "AND" : "OR") : null} + ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx deleted file mode 100644 index 29a7e65194e..00000000000 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React from "react"; -import { Icon } from "@webiny/ui/Icon"; -import { AccordionItem } from "@webiny/ui/Accordion"; -import { Alert } from "@webiny/ui/Alert"; -import { mdbid } from "@webiny/utils"; - -import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; -import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; - -import { RuleActionSelect } from "./RuleActionSelect"; -import { conditionChainOptions } from "./fieldsValidationConditions"; -import { getAvailableFields } from "./helpers"; -import { - RulesTabWrapper, - AddRuleButtonWrapper, - RuleButtonDescription, - StyledAccordion, - ConditionSetupWrapper, - AddRuleButton, - AddConditionButton, - ConditionsChainSelect -} from "../../Styled"; - -import { FbFormStep, FbFormModel, FbFormRule } from "~/types"; -import { BindComponent } from "@webiny/form/types"; -import { RuleCondition } from "./RuleCondition"; - -interface RulesTabProps { - bind: BindComponent; - step: FbFormStep; - formData: FbFormModel; -} - -export const RulesTab = ({ bind: Bind, step, formData }: RulesTabProps) => { - const fields = getAvailableFields({ step, formData }); - - const areRulesBroken = step.rules?.some(rule => rule.isValid === false); - const isCurrentStepLast = - formData.steps.findIndex(steps => steps.id === step.id) === formData.steps.length - 1; - - const rulesDisabledMessage = "You cannot add rules to the last step!"; - - // We also check whether last step has rules, - // if yes then we most block ability to add new rules and conditions. - if (isCurrentStepLast && !step?.rules?.length) { - return ( - -

{rulesDisabledMessage}

-
- ); - } - - return ( - - {areRulesBroken !== undefined && areRulesBroken === true && ( - - - At the moment one or more of your rules are broken. To correct the state - please check your rules and ensure they are referencing fields that still - exists and are place inside the current or one of the previous steps. - - - )} - - {({ value: stepRules, onChange: onChangeRules }) => ( - <> - {stepRules && - (stepRules as FbFormRule[]).map((rule, ruleIndex) => ( - - - } - onClick={() => - onChangeRules( - (stepRules as FbFormRule[]).filter( - rulesValueItem => - rulesValueItem.id !== rule.id - ) - ) - } - /> - - } - > - - {({ - value: stepRule, - onChange: onChangeRule - }: { - value: FbFormRule; - onChange: (params: any) => void; - }) => ( - <> - {stepRule.conditions.length === 0 ? ( - { - onChangeRule({ - ...stepRule, - conditions: [ - ...(stepRule.conditions || - []), - { - fieldName: "", - filterType: "", - filterValue: "", - id: mdbid() - } - ] - }); - }} - disabled={isCurrentStepLast} - > - + Add Condition - - ) : ( - stepRule.conditions.map( - (condition, conditionIndex) => ( - - { - onChangeRule({ - ...stepRule, - conditions: - stepRule.conditions.filter( - ruleCondition => - ruleCondition.id !== - condition.id - ) - }); - }} - /> - {condition.id === - stepRule.conditions[ - stepRule.conditions - .length - 1 - ].id && ( - <> - { - onChangeRule({ - ...stepRule, - conditions: - [ - ...(stepRule.conditions || - []), - { - fieldName: - "", - filterType: - "", - filterValue: - "", - id: mdbid() - } - ] - }); - }} - > - + Add Condition - - { - onChangeRule({ - ...stepRule, - chain: val - }); - }} - > - {conditionChainOptions.map( - ( - chainOption, - index - ) => ( - - ) - )} - - { - onChangeRule({ - ...stepRule, - action: val - }); - }} - /> - - )} - - ) - ) - )} - - )} - - - - ))} - - { - onChangeRules([ - ...(stepRules || []), - { - title: "Rule", - id: mdbid(), - conditions: [], - action: "hide", - isValid: true, - chain: "matchAny" - } - ]); - }} - > - + Add Rule - - - } /> - Click here to learn how step rules work - - - - )} - - - ); -}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx new file mode 100644 index 00000000000..619f425204a --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/AddRuleCondition.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from "react"; +import { mdbid } from "@webiny/utils"; + +import { AddConditionButton } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; +import { IconButton } from "@webiny/ui/Button"; +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; + +import { FbFormRule } from "~/types"; + +interface EmptyRuleProps { + rule: FbFormRule; + onChange: (value: FbFormRule) => void; +} + +export const AddRuleCondition = ({ rule, onChange }: EmptyRuleProps) => { + const onCreateCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: [ + ...(rule.conditions || []), + { + fieldName: "", + filterType: "", + filterValue: "", + id: mdbid() + } + ] + }); + }, [onChange, rule]); + + return ( + + } /> + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx new file mode 100644 index 00000000000..6c66d9c9b61 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rule.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { BindComponent } from "@webiny/form/types"; + +import { AddRuleCondition } from "./AddRuleCondition"; +import { RuleConditionWrapper } from "./RuleCondition"; +import { RuleAction } from "./RuleAction"; + +import { FbFormModelField, FbFormRule, FbFormStep } from "~/types"; + +interface RuleProps { + bind: BindComponent; + ruleIndex: number; + steps: FbFormStep[]; + currentStep: FbFormStep; + fields: (FbFormModelField | null)[]; +} + +interface BindParams { + value: FbFormRule; + onChange: (value: FbFormRule) => void; +} + +export const Rule = ({ bind: Bind, ruleIndex, steps, currentStep, fields }: RuleProps) => { + return ( + + {({ value: rule, onChange }: BindParams) => ( + <> + {rule.conditions.length === 0 ? ( + + ) : ( + <> + {rule.conditions.map((condition, conditionIndex) => ( + + ))} + + + )} + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx new file mode 100644 index 00000000000..311802db4f9 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleAction.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from "react"; + +import { FbFormRule, FbFormStep, FbFormRuleAction } from "~/types"; +import { SelectRuleAction } from "../SelectRuleAction"; + +interface RuleActionSelectProps { + rule: FbFormRule; + steps: FbFormStep[]; + currentStep: FbFormStep; + ruleIndex: number; + onChange: (params: FbFormRule) => void; +} + +export const RuleAction = ({ + rule, + ruleIndex, + steps, + currentStep, + onChange +}: RuleActionSelectProps) => { + const onChangeAction = useCallback( + (action: FbFormRuleAction) => { + return onChange({ + ...rule, + action + }); + }, + [rule, onChange, currentStep] + ); + + return ( + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx new file mode 100644 index 00000000000..1ad554869b0 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RuleCondition.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from "react"; + +import { RuleCondition, RuleConditionProps } from "../RuleCondition"; +import { AddRuleCondition } from "./AddRuleCondition"; + +export const RuleConditionWrapper = ({ + onChange, + rule, + condition, + ...rest +}: Omit) => { + const onDeleteCondition = useCallback(() => { + return onChange({ + ...rule, + conditions: rule.conditions.filter(ruleCondition => ruleCondition.id !== condition.id) + }); + }, [onChange, rule, condition]); + + const showAddConditionButton = condition.id === rule.conditions[rule.conditions.length - 1].id; + + return ( + <> + + {showAddConditionButton && } + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx new file mode 100644 index 00000000000..ea3f0618a2b --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/Rules.tsx @@ -0,0 +1,140 @@ +import React, { useCallback } from "react"; + +import { Rule } from "./Rule"; + +import { mdbid } from "@webiny/utils"; +import { BindComponent } from "@webiny/form/types"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { Switch } from "@webiny/ui/Switch"; + +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; + +import { FbFormModelField, FbFormRule, FbFormStep } from "~/types"; +import { + StyledAddRuleButton, + AddRuleButtonWrapper, + AccordionWithShadow +} from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +type OnChangeRulesHandler = (value: FbFormRule[]) => void; + +interface RulesProps { + bind: BindComponent; + steps: FbFormStep[]; + currentStep: FbFormStep; + fields: (FbFormModelField | null)[]; +} + +interface BindParams { + value: FbFormRule[]; + onChange: OnChangeRulesHandler; +} + +interface RulesAccordionProps { + children: React.ReactElement; + rules: FbFormRule[]; + rule: FbFormRule; + ruleIndex: number; + onChange: OnChangeRulesHandler; +} + +const RulesAccordion = ({ children, rule, rules, ruleIndex, onChange }: RulesAccordionProps) => { + const onDeleteRule = useCallback(() => { + return onChange(rules.filter(rulesValueItem => rulesValueItem.id !== rule.id)); + }, [rule, onChange]); + + const onChangeConditionChain = useCallback( + (matchAll: boolean) => { + rules[ruleIndex] = { + ...rules[ruleIndex], + matchAll + }; + return onChange([...rules]); + }, + [rule, onChange] + ); + + return ( + + + + } + /> + } onClick={onDeleteRule} /> + + } + > + {children} + + + ); +}; + +interface AddRuleButtonProps { + rules: FbFormRule[]; + onChange: (param: FbFormRule[]) => void; +} + +const AddRuleButton = ({ rules, onChange }: AddRuleButtonProps) => { + const onAddRule = useCallback(() => { + return onChange([ + ...(rules || []), + { + title: "Rule", + id: mdbid(), + conditions: [], + action: { + type: "", + value: "" + }, + isValid: true, + matchAll: false + } + ]); + }, [rules, onChange]); + + return ( + + + Add Rule + + ); +}; + +export const Rules = ({ bind: Bind, steps, currentStep, fields }: RulesProps) => { + return ( + + {({ value: rules, onChange }: BindParams) => ( + <> + {rules.map((rule, ruleIndex) => ( + + + + ))} + + + )} + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx new file mode 100644 index 00000000000..d16435f101c --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RulesTab/RulesTab.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Alert } from "@webiny/ui/Alert"; +import { getAvailableFields } from "../helpers"; +import { RulesTabWrapper } from "~/admin/components/FormEditor/Tabs/EditTab/Styled"; + +import { FbFormStep, FbFormModel, FbFormRule } from "~/types"; +import { BindComponent } from "@webiny/form/types"; +import { Rules } from "./Rules"; + +interface RulesTabProps { + bind: BindComponent; + step: FbFormStep; + formData: FbFormModel; +} + +const RulesBrokenAlert = ({ rules }: { rules: FbFormRule[] }) => { + const rulesBroken = rules.some(rule => rule.isValid === false); + + return rulesBroken !== undefined && rulesBroken === true ? ( + + + At the moment one or more of your rules are broken. To correct the state please + check your rules and ensure they are referencing fields that still exists and are + place inside the current or one of the previous steps. + + + ) : null; +}; + +export const RulesTab = ({ bind: Bind, step, formData }: RulesTabProps) => { + const fields = getAvailableFields({ step, formData }); + + const isCurrentStepLast = + formData.steps.findIndex(steps => steps.id === step.id) === formData.steps.length - 1; + + const rulesDisabledMessage = "You cannot add rules to the last step!"; + + // We also check whether last step has rules, + // if yes then we most block ability to add new rules and conditions. + if (isCurrentStepLast && !step?.rules?.length) { + return ( + +

{rulesDisabledMessage}

+
+ ); + } + + return ( + + + + + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx similarity index 60% rename from packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx rename to packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx index 8f9e6609be9..e0cf1d51c23 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/RuleActionSelect.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/SelectRuleAction.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Select } from "@webiny/ui/Select"; import styled from "@emotion/styled"; import { ruleActionOptions } from "./fieldsValidationConditions"; -import { FbFormStep, FbFormRule } from "~/types"; +import { FbFormStep, FbFormRule, FbFormRuleAction } from "~/types"; +import { Input } from "@webiny/ui/Input"; const RuleAction = styled("div")` display: flex; @@ -23,7 +24,6 @@ const RuleAction = styled("div")` `; const ActionSelect = styled(Select)` - margin-left: 35px; margin-right: 15px; width: 250px; `; @@ -37,28 +37,39 @@ interface Props { steps: FbFormStep[]; currentStep: FbFormStep; ruleIndex: number; - onChangeAction: (value: string) => void; + onChange: (action: FbFormRuleAction) => void; } -export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, onChangeAction }) => { - const defaultActionValue = rule.action === "submit" ? "submit" : "goToStep"; - const [ruleAction, setRuleAction] = useState(defaultActionValue); +export const SelectRuleAction = ({ rule, steps, currentStep, onChange }: Props) => { + const [ruleAction, setRuleAction] = useState(rule.action.type); // We can only select steps that are below current step. const availableSteps = steps.slice(steps.findIndex(step => step.id === currentStep.id) + 1); useEffect(() => { if (ruleAction === "submit") { - onChangeAction("submit"); + onChange({ + type: "submit", + value: "" + }); } }, [ruleAction]); + const onChangeAction = useCallback( + (actionValue: string) => { + return onChange({ + type: ruleAction, + value: actionValue + }); + }, + [ruleAction, rule.action.value] + ); + return ( - Then setRuleAction(val)} > @@ -70,9 +81,9 @@ export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, on {ruleAction === "goToStep" && ( {availableSteps.map((step, index) => ( @@ -82,6 +93,14 @@ export const RuleActionSelect: React.FC = ({ rule, steps, currentStep, on ))} )} + {ruleAction === "submitAndRedirect" && ( + + )} ); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts index 3226a723341..40b9065fa7c 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog/fieldsValidationConditions.ts @@ -96,5 +96,9 @@ export const ruleActionOptions = [ { value: "submit", label: "Submit" + }, + { + value: "submitAndRedirect", + label: "Submit & Redirect" } ]; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx index 413bd44cb28..0d0fcbfeabe 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStepWithFields/FormStepRowField.tsx @@ -80,7 +80,7 @@ export const FormStepRowField = (props: FormStepFieldRowFieldProps) => { return ( {({ drag }) => ( - + diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index 99647f1aed1..0f2ba93776a 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -4,7 +4,7 @@ import { ButtonSecondary } from "@webiny/ui/Button"; import { Select } from "@webiny/ui/Select"; export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` - background: var(--mdc-theme-background); + background: white; box-shadow: none; & > ul { padding: 0 0 0 0 !important; @@ -12,6 +12,14 @@ export const StyledAccordion = styled(Accordion)<{ margingap?: string }>` ${props => `margin-top: ${props.margingap}`} `; +export const AccordionWithShadow = styled(Accordion)<{ margingap?: string }>` + background: ${props => props.theme.styles.colors["color6"]}; + & > ul { + padding: 0 0 0 0 !important; + } + ${props => `margin-top: ${props.margingap}`} +`; + export const StyledAccordionItem = styled(AccordionItem)` & .webiny-ui-accordion-item__content { background: white; @@ -60,13 +68,15 @@ export const RuleButtonDescription = styled.div` export const ConditionSetupWrapper = styled.div``; -export const AddRuleButton = styled(ButtonSecondary)` +export const StyledAddRuleButton = styled(ButtonSecondary)` width: 150px; `; -export const AddConditionButton = styled(ButtonSecondary)` - border: none; - margin: 10px 0 10px 80px; +export const AddConditionButton = styled("div")` + width: 100%; + display: flex; + justify-content: center; + align-items: center; padding: 0; `; @@ -142,22 +152,22 @@ export const ConditionGroupContainer = styled.div({ } }); -export const FieldContainer = styled.div({ - position: "relative", - flex: "1 100%", - backgroundColor: "var(--mdc-theme-background)", - padding: "0 15px", - margin: 10, - borderRadius: 2, - border: "1px solid var(--mdc-theme-on-background)", - transition: "box-shadow 225ms", - color: "var(--mdc-theme-on-surface)", - cursor: "grab", - "&:hover": { - boxShadow: - "var(--mdc-theme-on-background) 1px 1px 1px, var(--mdc-theme-on-background) 1px 1px 2px" +export const FieldContainer = styled.div<{ noPadding?: boolean }>` + padding: ${props => (props.noPadding ? "0" : "0 15px")}; + position: relative; + flex: 1 100%; + background-color: var(--mdc-theme-background); + margin: 10px; + border-radius: 2px; + border: 1px solid var(--mdc-theme-on-background); + transition: box-shadow 225ms; + color: var(--mdc-theme-on-surface); + cursor: grab; + & :hover { + box-shadow: var(--mdc-theme-on-background) 1px 1px 1px, + var(--mdc-theme-on-background) 1px 1px 2px; } -}); +`; export const RowHandle = styled.div({ width: 30, diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index 7f0703c8dd1..dd537610fc5 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -1,6 +1,6 @@ +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { plugins } from "@webiny/plugins"; import cloneDeep from "lodash/cloneDeep"; -import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./components"; import { @@ -9,7 +9,8 @@ import { onFormMounted, reCaptchaEnabled, termsOfServiceEnabled, - getNextStepIndex + getNextStepIndex, + onFormDataChange } from "./functions"; import { checkIfConditionsMet } from "./functions/getNextStepIndex"; @@ -48,7 +49,7 @@ const FormRender = (props: FbFormRenderComponentProps) => { const client = useApolloClient(); const data = props.data || ({} as FbFormModel); const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [formState, setFormState] = useState(); + const [formState, setFormState] = useState>({}); const [layoutRenderKey, setLayoutRenderKey] = useState(new Date().getTime().toString()); const resetLayoutRenderKey = useCallback(() => { @@ -75,13 +76,17 @@ const FormRender = (props: FbFormRenderComponentProps) => { const [modifiedSteps, setModifiedSteps] = useState(data.steps); + // This variable will trigger update of "modifiedSteps", + // when we modify rules of the step. + const shouldUpdateModifiedSteps = onFormDataChange(data); + // We need this useEffect in case when user has deleted a step and he was on that step on the preview tab, // so it won't trigger an error when we trying to view the step that we have deleted, // we will simply change currentStep to the first step. useEffect(() => { setCurrentStepIndex(0); setModifiedSteps(data.steps); - }, [data.steps.length, data.fields.length]); + }, [data.steps.length, data.fields.length, shouldUpdateModifiedSteps]); const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -133,17 +138,17 @@ const FormRender = (props: FbFormRenderComponentProps) => { const validateStepConditions = (formData: Record, stepIndex: number) => { const currentStep = resolvedSteps[stepIndex]; - const nextStepIndex = getNextStepIndex({ + const action = getNextStepIndex({ formData, rules: currentStep.rules }); - if (nextStepIndex === "submit") { + if (action.type === "submit") { setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); - } else if (nextStepIndex !== "") { + } else if (action.type === "goToStep") { setModifiedSteps([ ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(+nextStepIndex) + ...steps.slice(+action.value) ]); } else { setModifiedSteps([ @@ -172,7 +177,7 @@ const FormRender = (props: FbFormRenderComponentProps) => { if (field.settings?.rules.length) { field.settings.rules.forEach((rule: FbFormRule) => { if (checkIfConditionsMet({ formData: formState, rule })) { - if (rule.action === "show") { + if (rule.action.value === "show") { fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); } else { fieldLayout.splice(fieldIndex, field.settings.layout.length, [ diff --git a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts index b8bc6c6baf6..f8116b4b974 100644 --- a/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts +++ b/packages/app-form-builder/src/components/Form/functions/getNextStepIndex.ts @@ -1,6 +1,6 @@ -import { validation } from "@webiny/validation"; +import * as validators from "~/validators"; -import { FbFormCondition, FbFormRule } from "~/types"; +import { FbFormCondition, FbFormRule, FbFormRuleAction } from "~/types"; interface Props { formData: Record; @@ -12,7 +12,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return fieldValue.includes(filterValue); + return validators.includes(fieldValue, filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -20,14 +20,7 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - // Need to use try catch block because without it validation will throw and error, - // so user won't be able to interact with page. - // Same applies to all validation methods below. - try { - return validation.validateSync(fieldValue, `starts:${filterValue}`); - } catch { - return; - } + return validators.startsWith(fieldValue, filterValue); }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,11 +28,7 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - try { - return validation.validateSync(fieldValue, `ends:${filterValue}`); - } catch { - return; - } + return validators.endsWith(fieldValue, filterValue); }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -47,16 +36,7 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - try { - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return validation.validateSync(fieldValue, `eq:${filterValue}`); - } - } catch { - return; - } + return validators.is(fieldValue, filterValue); }; const gtValidator = ({ @@ -72,14 +52,10 @@ const gtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `gt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `gte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); } }; @@ -96,14 +72,10 @@ const ltValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `lt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `lte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); } }; @@ -120,17 +92,10 @@ const timeGtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `gte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -147,17 +112,10 @@ const timeLtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `lte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -205,7 +163,7 @@ const checkCondition = (condition: FbFormCondition, fieldValue: string) => { }; export const checkIfConditionsMet = ({ formData, rule }: Props) => { - if (rule.chain === "matchAll") { + if (rule.matchAll) { let isValid = true; rule.conditions.forEach(condition => { @@ -236,13 +194,16 @@ interface GetNextStepIndexProps { } export default ({ formData, rules }: GetNextStepIndexProps) => { - let nextStepIndex = ""; + let action: FbFormRuleAction = { + type: "", + value: "" + }; rules.forEach(rule => { if (checkIfConditionsMet({ formData, rule })) { - nextStepIndex = rule.action; + action = rule.action; return; } }); - return nextStepIndex; + return action; }; diff --git a/packages/app-form-builder/src/components/Form/functions/index.ts b/packages/app-form-builder/src/components/Form/functions/index.ts index e8fd490fd17..b68c448aea5 100644 --- a/packages/app-form-builder/src/components/Form/functions/index.ts +++ b/packages/app-form-builder/src/components/Form/functions/index.ts @@ -4,3 +4,5 @@ export { default as handleFormTriggers } from "./handleFormTriggers"; export { default as reCaptchaEnabled } from "./reCaptchaEnabled"; export { default as termsOfServiceEnabled } from "./termsOfServiceEnabled"; export { default as getNextStepIndex } from "./getNextStepIndex"; +export { default as usePrevious } from "./usePrevious"; +export { default as onFormDataChange } from "./onFormDataChange"; diff --git a/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts b/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts new file mode 100644 index 00000000000..caf61fef76c --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/onFormDataChange.ts @@ -0,0 +1,8 @@ +import usePrevious from "./usePrevious"; +import isEqual from "lodash/isEqual"; + +export default function (value: T) { + const previousValue = usePrevious(value); + + return !isEqual(previousValue, value); +} diff --git a/packages/app-form-builder/src/components/Form/functions/usePrevious.ts b/packages/app-form-builder/src/components/Form/functions/usePrevious.ts new file mode 100644 index 00000000000..e7adc312f35 --- /dev/null +++ b/packages/app-form-builder/src/components/Form/functions/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export default function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} diff --git a/packages/app-form-builder/src/components/Form/graphql.ts b/packages/app-form-builder/src/components/Form/graphql.ts index 9a65f38789a..ac839854de8 100644 --- a/packages/app-form-builder/src/components/Form/graphql.ts +++ b/packages/app-form-builder/src/components/Form/graphql.ts @@ -30,8 +30,11 @@ export const DATA_FIELDS = ` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index f368686f0e3..4f27022eabc 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -152,9 +152,14 @@ export interface FbFormStep { index: number; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/app-form-builder/src/validators/endsWith.ts b/packages/app-form-builder/src/validators/endsWith.ts new file mode 100644 index 00000000000..49ca8f34cb1 --- /dev/null +++ b/packages/app-form-builder/src/validators/endsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const endOfTheString = value.slice(value.length - param.length); + + return endOfTheString === param; +}; diff --git a/packages/app-form-builder/src/validators/gt.ts b/packages/app-form-builder/src/validators/gt.ts new file mode 100644 index 00000000000..4ac87a9f31a --- /dev/null +++ b/packages/app-form-builder/src/validators/gt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value >= param; + } else { + return value > param; + } +}; diff --git a/packages/app-form-builder/src/validators/includes.ts b/packages/app-form-builder/src/validators/includes.ts new file mode 100644 index 00000000000..2de844cd8ce --- /dev/null +++ b/packages/app-form-builder/src/validators/includes.ts @@ -0,0 +1,7 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return false; + } + + return value.includes(param); +}; diff --git a/packages/app-form-builder/src/validators/index.ts b/packages/app-form-builder/src/validators/index.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-form-builder/src/validators/index.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-form-builder/src/validators/is.ts b/packages/app-form-builder/src/validators/is.ts new file mode 100644 index 00000000000..28591003023 --- /dev/null +++ b/packages/app-form-builder/src/validators/is.ts @@ -0,0 +1,11 @@ +export default (value: string | string[], param: string) => { + if (!value || !param) { + return false; + } + + if (typeof param === "object") { + return value.includes(param); + } else { + return value === param; + } +}; diff --git a/packages/app-form-builder/src/validators/lt.ts b/packages/app-form-builder/src/validators/lt.ts new file mode 100644 index 00000000000..e6a5c5b668e --- /dev/null +++ b/packages/app-form-builder/src/validators/lt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value <= param; + } else { + return value < param; + } +}; diff --git a/packages/app-form-builder/src/validators/startsWith.ts b/packages/app-form-builder/src/validators/startsWith.ts new file mode 100644 index 00000000000..9f001a731c3 --- /dev/null +++ b/packages/app-form-builder/src/validators/startsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const startOfString = value.slice(0, param.length); + + return startOfString === param; +}; diff --git a/packages/app-headless-cms-common/src/entries.graphql.ts b/packages/app-headless-cms-common/src/entries.graphql.ts index 688cde9ca0a..a0bbc848cad 100644 --- a/packages/app-headless-cms-common/src/entries.graphql.ts +++ b/packages/app-headless-cms-common/src/entries.graphql.ts @@ -16,7 +16,6 @@ const CONTENT_META_FIELDS = /* GraphQL */ ` title description image - publishedOn version locked status @@ -28,12 +27,14 @@ const CONTENT_ENTRY_SYSTEM_FIELDS = /* GraphQL */ ` entryId savedOn createdOn - createdBy { + firstPublishedOn + lastPublishedOn + revisionCreatedBy { id type displayName } - ownedBy { + createdBy { id type displayName diff --git a/packages/app-headless-cms-common/src/prepareFormData.ts b/packages/app-headless-cms-common/src/prepareFormData.ts index ab26d85b33c..aa0ec1cc123 100644 --- a/packages/app-headless-cms-common/src/prepareFormData.ts +++ b/packages/app-headless-cms-common/src/prepareFormData.ts @@ -1,4 +1,4 @@ -import { CmsContentEntry, CmsFieldValueTransformer, CmsModelField } from "~/types"; +import { CmsFieldValueTransformer, CmsModelField } from "~/types"; import { plugins } from "@webiny/plugins"; interface AvailableFieldTransformers { @@ -54,15 +54,17 @@ const createTransformationRunner = (): TransformationRunnerCallable => { return transformationRunner; }; -export const prepareFormData = ( - input: Partial, +export const prepareFormData = >( + input: T, fields: CmsModelField[] -): CmsContentEntry => { +): T => { const runTransformation = createTransformationRunner(); - return fields.reduce((output, field) => { + return fields.reduce>((output, field) => { const inputValue = input[field.fieldId]; + const fieldId: keyof T = field.fieldId; + if (field.multipleValues) { const values = Array.isArray(inputValue) ? inputValue : undefined; if (!values) { @@ -79,14 +81,16 @@ export const prepareFormData = ( else if (values.length === 1 && (values[0] === null || values[0] === undefined)) { return output; } - output[field.fieldId] = values.map(value => runTransformation(field, value)); + + output[fieldId] = values.map(value => runTransformation(field, value)); + return output; } /** * Regular values, single values. */ - output[field.fieldId] = runTransformation(field, inputValue); + output[fieldId] = runTransformation(field, inputValue); return output; - }, {} as CmsContentEntry); + }, {} as T); }; diff --git a/packages/app-headless-cms-common/src/types/index.ts b/packages/app-headless-cms-common/src/types/index.ts index 8c7df409cf6..a474ca8eace 100644 --- a/packages/app-headless-cms-common/src/types/index.ts +++ b/packages/app-headless-cms-common/src/types/index.ts @@ -343,15 +343,16 @@ export type CmsEditorContentEntry = CmsContentEntry; export interface CmsContentEntry { id: string; - savedOn: string; modelId: string; + savedOn: string; createdBy: CmsIdentity; + firstPublishedOn: string | null; + lastPublishedOn: string | null; wbyAco_location: Location; meta: { title: string; description?: string; image?: string; - publishedOn: string; locked: boolean; status: CmsContentEntryStatusType; version: number; @@ -361,13 +362,14 @@ export interface CmsContentEntry { export interface CmsContentEntryRevision { id: string; - savedOn: string; modelId: string; + savedOn: string; + firstPublishedOn: string | null; + lastPublishedOn: string | null; createdBy: CmsIdentity; wbyAco_location: Location; meta: { title: string; - publishedOn: string; locked: boolean; status: CmsContentEntryStatusType; version: number; diff --git a/packages/app-headless-cms-common/src/types/model.ts b/packages/app-headless-cms-common/src/types/model.ts index 9803638f0e4..bd6cbc24236 100644 --- a/packages/app-headless-cms-common/src/types/model.ts +++ b/packages/app-headless-cms-common/src/types/model.ts @@ -65,7 +65,7 @@ export interface CmsGroup { id: string; name: string; slug: string; - icon?: string; + icon: string; description?: string; contentModels: CmsModel[]; createdBy: CmsIdentity; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/ChangeEntryStatus.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/ChangeEntryStatus.tsx new file mode 100644 index 00000000000..30aa4e59756 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/ChangeEntryStatus.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { ReactComponent as Publish } from "@material-design-icons/svg/outlined/publish.svg"; +import { ReactComponent as Unpublish } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useChangeEntryStatus, useEntry, usePermission } from "~/admin/hooks"; + +export const ChangeEntryStatus = () => { + const { entry } = useEntry(); + const { canPublish, canUnpublish } = usePermission(); + const { openDialogPublishEntry, openDialogUnpublishEntry } = useChangeEntryStatus({ entry }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + if (entry.meta.status === "published" && canUnpublish("cms.contentEntry")) { + return ( + } + label={"Unpublish"} + onAction={openDialogUnpublishEntry} + data-testid={"aco.actions.entry.unpublish"} + /> + ); + } + + if (!canPublish("cms.contentEntry")) { + return null; + } + + return ( + } + label={"Publish"} + onAction={openDialogPublishEntry} + data-testid={"aco.actions.entry.publish"} + /> + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/DeleteEntry.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/DeleteEntry.tsx new file mode 100644 index 00000000000..09af3894284 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/DeleteEntry.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useDeleteEntry, useEntry, usePermission } from "~/admin/hooks"; + +export const DeleteEntry = () => { + const { entry } = useEntry(); + const { canDelete } = usePermission(); + const { openDialogDeleteEntry } = useDeleteEntry({ entry }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + if (!canDelete(entry, "cms.contentEntry")) { + return null; + } + + return ( + } + label={"Delete"} + onAction={openDialogDeleteEntry} + data-testid={"aco.actions.entry.delete"} + /> + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/EditEntry.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/EditEntry.tsx new file mode 100644 index 00000000000..b4d5b5a4d6b --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/EditEntry.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useContentEntriesList, useEntry, usePermission } from "~/admin/hooks"; + +export const EditEntry = () => { + const { entry } = useEntry(); + const { canEdit } = usePermission(); + const { getEntryEditUrl } = useContentEntriesList(); + const { OptionsMenuLink } = AcoConfig.Record.Action; + + if (!canEdit(entry, "cms.contentEntry")) { + return null; + } + + return ( + } + label={"Edit"} + to={getEntryEditUrl(entry)} + data-testid={"aco.actions.entry.edit"} + /> + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/MoveEntry.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/MoveEntry.tsx new file mode 100644 index 00000000000..1b939adc77d --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/MoveEntry.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { useEntry, useMoveContentEntryToFolder } from "~/admin/hooks"; + +export const MoveEntry = () => { + const { entry: record } = useEntry(); + const moveContentEntry = useMoveContentEntryToFolder({ record }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + return ( + } + label={"Move"} + onAction={moveContentEntry} + data-testid={"aco.actions.entry.move"} + /> + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/index.ts b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/index.ts new file mode 100644 index 00000000000..a540e0a054f --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Actions/index.ts @@ -0,0 +1,4 @@ +export * from "./ChangeEntryStatus"; +export * from "./DeleteEntry"; +export * from "./EditEntry"; +export * from "./MoveEntry"; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx index 599d90acda5..631d1ae0550 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellActions.tsx @@ -1,24 +1,13 @@ import React from "react"; - -import { ReactComponent as MoreIcon } from "@material-design-icons/svg/filled/more_vert.svg"; import { FolderProvider, useAcoConfig } from "@webiny/app-aco"; import { OptionsMenu } from "@webiny/app-admin"; -import { IconButton } from "@webiny/ui/Button"; -import { Menu } from "@webiny/ui/Menu"; - import { ContentEntryListConfig } from "~/admin/config/contentEntries"; - -import { RecordActionDelete } from "../Row/Record/RecordActionDelete"; -import { RecordActionEdit } from "../Row/Record/RecordActionEdit"; -import { RecordActionMove } from "../Row/Record/RecordActionMove"; -import { RecordActionPublish } from "../Row/Record/RecordActionPublish"; - -import { menuStyles } from "./Cells.styled"; +import { EntryProvider } from "~/admin/hooks/useEntry"; export const CellActions = () => { const { useTableRow, isFolderRow } = ContentEntryListConfig.Browser.Table.Column; const { row } = useTableRow(); - const { folder: folderConfig } = useAcoConfig(); + const { folder: folderConfig, record: recordConfig } = useAcoConfig(); if (isFolderRow(row)) { // If the user cannot manage folder structure, no need to show the menu. @@ -37,11 +26,11 @@ export const CellActions = () => { } return ( - } />}> - - - - - + + + ); }; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellName.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellName.tsx index 59c5881db29..1c4f3c096ea 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellName.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/CellName.tsx @@ -7,8 +7,9 @@ import { useNavigateFolder } from "@webiny/app-aco"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; import { useContentEntriesList } from "~/admin/views/contentEntries/hooks"; +import { usePermission } from "~/admin/hooks"; -import { RowIcon, RowText, RowTitle } from "./Cells.styled"; +import { LinkTitle, RowIcon, RowText, RowTitle } from "./Cells.styled"; import { FolderTableItem } from "@webiny/app-aco/types"; import { EntryTableItem } from "~/types"; @@ -33,15 +34,13 @@ export const FolderCellName = ({ folder }: FolderCellNameProps) => { ); }; -interface EntryCellNameProps { +interface EntryCellRowTitleProps { entry: EntryTableItem; } -export const EntryCellName = ({ entry }: EntryCellNameProps) => { - const { onEditEntry } = useContentEntriesList(); - +const EntryCellRowTitle = ({ entry }: EntryCellRowTitleProps) => { return ( - onEditEntry(entry)}> + @@ -50,6 +49,27 @@ export const EntryCellName = ({ entry }: EntryCellNameProps) => { ); }; +interface EntryCellNameProps { + entry: EntryTableItem; +} + +export const EntryCellName = ({ entry }: EntryCellNameProps) => { + const { getEntryEditUrl } = useContentEntriesList(); + const { canEdit } = usePermission(); + + const entryEditUrl = getEntryEditUrl(entry); + + if (!canEdit(entry, "cms.contentEntry")) { + return ; + } + + return ( + + + + ); +}; + export const CellName = () => { const { useTableRow, isFolderRow } = ContentEntryListConfig.Browser.Table.Column; const { row } = useTableRow(); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/Cells.styled.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/Cells.styled.tsx index 10eab5ebe23..96e04e294cb 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/Cells.styled.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Cells/Cells.styled.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { css } from "emotion"; +import { Link } from "@webiny/react-router"; import { Typography } from "@webiny/ui/Typography"; export const RowTitle = styled("div")` @@ -8,6 +8,10 @@ export const RowTitle = styled("div")` cursor: pointer; `; +export const LinkTitle = styled(Link)` + color: var(--mdc-theme-text-primary-on-background); +`; + export const RowIcon = styled("div")` margin-right: 8px; height: 24px; @@ -18,7 +22,3 @@ export const RowText = styled(Typography)` overflow: hidden; text-overflow: ellipsis; `; - -export const menuStyles = css(` - width: 200px; -`); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionDelete.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionDelete.tsx deleted file mode 100644 index 2ef08a77acc..00000000000 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionDelete.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useCallback } from "react"; -import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { ListItemGraphic } from "@webiny/ui/List"; -import { useCms, useModel, usePermission } from "~/admin/hooks"; -import { useConfirmationDialog, useDialog, useSnackbar } from "@webiny/app-admin"; -import { parseIdentifier } from "@webiny/utils"; -import { useNavigateFolder, useRecords } from "@webiny/app-aco"; -import { EntryTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/admin/components/content-entries/table"); - -interface RecordActionDeleteProps { - record: EntryTableItem; -} - -export const RecordActionDelete = ({ record }: RecordActionDeleteProps) => { - const { deleteEntry } = useCms(); - const { canDelete } = usePermission(); - const { model } = useModel(); - const { showSnackbar } = useSnackbar(); - const { showDialog } = useDialog(); - const { showConfirmation } = useConfirmationDialog({ - title: t`Delete content entry`, - message: ( -

- {t`You are about to delete this content entry and all of its revisions!`} -
- {t`Are you sure you want to permanently delete {title}?`({ - title: {record.meta.title} - })} -

- ), - dataTestId: "cms.content-form.header.delete-dialog" - }); - const { navigateToLatestFolder } = useNavigateFolder(); - const { removeRecordFromCache } = useRecords(); - - const onClick = useCallback((): void => { - showConfirmation(async (): Promise => { - const { id } = parseIdentifier(record.id); - const { error } = await deleteEntry({ - model, - entry: { - id: record.id - }, - id - }); - - if (error) { - showDialog(error.message, { title: t`Could not delete content` }); - return; - } - - showSnackbar( - t`{title} was deleted successfully!`({ - title: {record.meta.title} - }) - ); - removeRecordFromCache(record.id); - navigateToLatestFolder(); - }); - }, [record.id]); - - if (!canDelete(record, "cms.contentEntry")) { - console.log("Does not have permission to delete CMS entry."); - return null; - } - - return ( - - - } /> - - {t`Delete`} - - ); -}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionEdit.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionEdit.tsx deleted file mode 100644 index f315f16edd9..00000000000 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionEdit.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { ListItemGraphic } from "@webiny/ui/List"; -import { useContentEntriesList, usePermission } from "~/admin/hooks"; -import { EntryTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/admin/components/content-entries/table"); - -interface RecordActionEditProps { - record: EntryTableItem; -} - -export const RecordActionEdit = ({ record }: RecordActionEditProps) => { - const { onEditEntry } = useContentEntriesList(); - const { canEdit } = usePermission(); - - if (!canEdit(record, "cms.contentEntry")) { - return null; - } - - return ( - onEditEntry(record)}> - - } /> - - {t`Edit`} - - ); -}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionMove.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionMove.tsx deleted file mode 100644 index ed0836dae09..00000000000 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionMove.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { ListItemGraphic } from "@webiny/ui/List"; -import { useMoveContentEntryToFolder } from "~/admin/views/contentEntries/hooks"; -import { EntryTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/admin/components/content-entries/table"); - -interface RecordActionMoveProps { - record: EntryTableItem; -} -export const RecordActionMove = ({ record }: RecordActionMoveProps) => { - const moveContentEntry = useMoveContentEntryToFolder({ record }); - - return ( - - - } /> - - {t`Move`} - - ); -}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionPublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionPublish.tsx deleted file mode 100644 index 3248af56cd4..00000000000 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Table/Row/Record/RecordActionPublish.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import { ReactComponent as Publish } from "@material-design-icons/svg/outlined/publish.svg"; -import { ReactComponent as Restore } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; -import { useConfirmationDialog } from "@webiny/app-admin"; -import { i18n } from "@webiny/app/i18n"; -import { MenuItem } from "@webiny/ui/Menu"; -import { Icon } from "@webiny/ui/Icon"; -import { ListItemGraphic } from "@webiny/ui/List"; -import { usePermission } from "~/admin/hooks"; -import { useRevision } from "~/admin/views/contentEntries/ContentEntry/useRevision"; -import { EntryTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/pages-table/actions/page/publish"); - -interface RecordActionPublishProps { - record: EntryTableItem; -} - -export const RecordActionPublish = ({ record }: RecordActionPublishProps) => { - const { canPublish, canUnpublish } = usePermission(); - - const { unpublishRevision, publishRevision } = useRevision({ - revision: { - id: record.id, - meta: { - version: record.meta.version - } - } - }); - - const { showConfirmation: showPublishConfirmation } = useConfirmationDialog({ - title: t`Publish CMS Entry`, - message: ( -

- {t`You are about to publish the {title} CMS entry. Are you sure you want to continue?`( - { - title: {record.meta.title} - } - )} -

- ) - }); - - const { showConfirmation: showUnpublishConfirmation } = useConfirmationDialog({ - title: t`Unpublish CMS Entry`, - message: ( -

- {t`You are about to unpublish the {title} CMS entry. Are you sure you want to continue?`( - { - title: {record.meta.title} - } - )} -

- ) - }); - - if (record.meta.status === "published" && canUnpublish("cms.contentEntry")) { - return ( - - showUnpublishConfirmation(async () => { - await unpublishRevision(record.id); - }) - } - > - - } /> - - {t`Unpublish`} - - ); - } - - if (!canPublish("cms.contentEntry")) { - return null; - } - - return ( - - showPublishConfirmation(async () => { - await publishRevision(record.id); - }) - } - > - - } /> - - {t`Publish`} - - ); -}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts index d5db5c20723..63251a23834 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts @@ -298,8 +298,9 @@ export function useContentEntryForm(params: UseContentEntryFormParams): UseConte const formData = pick(data, [...fieldsIds]); const gqlData = prepareFormData(formData, model.fields); + if (!entry.id) { - return createContent(gqlData, form); + return createContent(gqlData as CmsContentEntry, form); } const { meta } = entry; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx new file mode 100644 index 00000000000..66f2c6e5b2d --- /dev/null +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { CompositionScope } from "@webiny/react-composition"; +import { AcoConfig, RecordActionConfig } from "@webiny/app-aco"; +import { useModel } from "~/admin/hooks"; + +const { Record } = AcoConfig; + +export { RecordActionConfig as EntryActionConfig }; + +export interface EntryActionProps extends React.ComponentProps { + modelIds?: string[]; +} + +export const EntryAction = ({ modelIds = [], ...props }: EntryActionProps) => { + const { model } = useModel(); + + if (modelIds.length > 0 && !modelIds.includes(model.modelId)) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx index 2b0592bfcc0..cb973150432 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx @@ -12,7 +12,7 @@ export interface ColumnProps extends React.ComponentProps = ({ modelIds = [], ...props }) => { +const BaseColumn = ({ modelIds = [], ...props }: ColumnProps) => { const { model } = useModel(); if (modelIds.length > 0 && !modelIds.includes(model.modelId)) { diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/index.ts b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/index.ts index 7d8e28e2196..88b6208e287 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/index.ts +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/index.ts @@ -1,4 +1,5 @@ import { BulkAction, BulkActionConfig } from "./BulkAction"; +import { EntryAction, EntryActionConfig } from "./EntryAction"; import { Filter, FilterConfig } from "./Filter"; import { FiltersToWhere, FiltersToWhereConverter } from "./FiltersToWhere"; import { FolderAction, FolderActionConfig } from "./FolderAction"; @@ -6,6 +7,7 @@ import { Table, TableConfig } from "./Table"; export interface BrowserConfig { bulkActions: BulkActionConfig[]; + entryActions: EntryActionConfig[]; filters: FilterConfig[]; filtersToWhere: FiltersToWhereConverter[]; folderActions: FolderActionConfig[]; @@ -14,6 +16,7 @@ export interface BrowserConfig { export const Browser = { BulkAction, + EntryAction, Filter, FiltersToWhere, FolderAction, diff --git a/packages/app-headless-cms/src/admin/hooks/index.ts b/packages/app-headless-cms/src/admin/hooks/index.ts index bd3c56b93ec..f460bfc1fed 100644 --- a/packages/app-headless-cms/src/admin/hooks/index.ts +++ b/packages/app-headless-cms/src/admin/hooks/index.ts @@ -9,6 +9,9 @@ export { useModel } from "../components/ModelProvider"; export { useModelEditor } from "../components/ContentModelEditor"; export { useModelField } from "../components/ModelFieldProvider"; export { useModelFieldEditor } from "../components/FieldEditor"; +export { useChangeEntryStatus } from "~/admin/hooks/useChangeEntryStatus"; +export { useDeleteEntry } from "~/admin/hooks/useDeleteEntry"; +export { useEntry } from "~/admin/hooks/useEntry"; export * from "./useContentModels"; export * from "~/admin/views/contentEntries/hooks"; export * from "./useModelFieldGraphqlContext"; diff --git a/packages/app-headless-cms/src/admin/hooks/useChangeEntryStatus.tsx b/packages/app-headless-cms/src/admin/hooks/useChangeEntryStatus.tsx new file mode 100644 index 00000000000..1f9ce442c3c --- /dev/null +++ b/packages/app-headless-cms/src/admin/hooks/useChangeEntryStatus.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } from "react"; +import { useConfirmationDialog, useSnackbar } from "@webiny/app-admin"; +import { useRecords } from "@webiny/app-aco"; +import { useCms, useContentEntry } from "~/admin/hooks/index"; +import { CmsContentEntry } from "~/types"; + +interface UseChangeEntryStatusParams { + entry: CmsContentEntry; +} + +export const useChangeEntryStatus = ({ entry }: UseChangeEntryStatusParams) => { + const { publishEntryRevision, unpublishEntryRevision } = useCms(); + const { contentModel } = useContentEntry(); + const { showSnackbar } = useSnackbar(); + const { updateRecordInCache } = useRecords(); + + const { showConfirmation: showPublishConfirmation } = useConfirmationDialog({ + title: "Publish CMS Entry", + message: `You are about to publish the "${entry.meta.title}" CMS entry. Are you sure you want to continue?` + }); + + const { showConfirmation: showUnpublishConfirmation } = useConfirmationDialog({ + title: "Unpublish CMS Entry", + message: `You are about to unpublish the "${entry.meta.title}" CMS entry. Are you sure you want to continue?` + }); + + const openDialogPublishEntry = useCallback( + () => + showPublishConfirmation(async () => { + const response = await publishEntryRevision({ + model: contentModel, + entry, + id: entry.id + }); + + const { error, entry: entryResult } = response; + if (error) { + showSnackbar(error.message); + return; + } + + updateRecordInCache(entryResult); + + showSnackbar( + + Successfully published revision{" "} + #{response.entry!.meta.version}! + + ); + }), + [entry.id, contentModel] + ); + + const openDialogUnpublishEntry = useCallback( + () => + showUnpublishConfirmation(async () => { + const response = await unpublishEntryRevision({ + model: contentModel, + entry, + id: entry.id + }); + + const { error, entry: entryResult } = response; + + if (error) { + showSnackbar(error.message); + return; + } + + updateRecordInCache(entryResult); + + showSnackbar( + + Successfully unpublished revision #{entry.meta.version}! + + ); + }), + [entry.id, contentModel] + ); + + return { + openDialogPublishEntry, + openDialogUnpublishEntry + }; +}; diff --git a/packages/app-headless-cms/src/admin/hooks/useDeleteEntry.tsx b/packages/app-headless-cms/src/admin/hooks/useDeleteEntry.tsx new file mode 100644 index 00000000000..6adb9b1ff10 --- /dev/null +++ b/packages/app-headless-cms/src/admin/hooks/useDeleteEntry.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from "react"; +import get from "lodash/get"; +import { useConfirmationDialog, useDialog, useSnackbar } from "@webiny/app-admin"; +import { parseIdentifier } from "@webiny/utils"; +import { useCms, useModel } from "~/admin/hooks"; +import { useNavigateFolder, useRecords } from "@webiny/app-aco"; +import { CmsContentEntry } from "@webiny/app-headless-cms-common/types"; + +interface UseDeleteEntryParams { + entry: CmsContentEntry; + onAccept?: () => void; + onCancel?: () => void; +} + +export const useDeleteEntry = ({ entry, onAccept, onCancel }: UseDeleteEntryParams) => { + const { deleteEntry } = useCms(); + const { model } = useModel(); + const { showSnackbar } = useSnackbar(); + const { showDialog } = useDialog(); + const { navigateToLatestFolder } = useNavigateFolder(); + const { removeRecordFromCache } = useRecords(); + + const title = get(entry, "meta.title"); + const { showConfirmation } = useConfirmationDialog({ + title: "Delete content entry", + message: ( +

+ You are about to delete this content entry and all of its revisions! +
+ Are you sure you want to permanently delete {title}? +

+ ), + dataTestId: "cms.content-form.header.delete-dialog" + }); + + const openDialogDeleteEntry = useCallback( + () => + showConfirmation(async () => { + const { id: entryId } = parseIdentifier(entry.id); + const { error } = await deleteEntry({ + model, + entry, + id: entryId + }); + + if (error) { + showDialog(error.message, { title: "Could not delete content!" }); + return; + } + + showSnackbar(`${title} was deleted successfully!`); + removeRecordFromCache(entry.id); + navigateToLatestFolder(); + + if (typeof onAccept === "function") { + await onAccept(); + } + }, onCancel), + [entry] + ); + + return { openDialogDeleteEntry }; +}; diff --git a/packages/app-headless-cms/src/admin/hooks/useEntry.tsx b/packages/app-headless-cms/src/admin/hooks/useEntry.tsx new file mode 100644 index 00000000000..9717fa7d4d6 --- /dev/null +++ b/packages/app-headless-cms/src/admin/hooks/useEntry.tsx @@ -0,0 +1,30 @@ +import React, { createContext } from "react"; +import { EntryTableItem } from "~/types"; + +export interface EntryContext { + entry: EntryTableItem; +} + +export const EntryContext = createContext(undefined); + +interface EntryProviderProps { + entry: EntryTableItem; + children: React.ReactNode; +} + +export const EntryProvider = ({ entry, children }: EntryProviderProps) => { + const value: EntryContext = { entry }; + + return {children}; +}; + +export const useEntry = () => { + const context = React.useContext(EntryContext); + if (!context) { + throw Error( + `EntryContext is missing in the component tree. Are you using "useEntry()" hook in the right place?` + ); + } + + return context; +}; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/advanced/hooks/graphql.ts b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/advanced/hooks/graphql.ts index 03718a7a5bb..3e781d20639 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/advanced/hooks/graphql.ts +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/advanced/hooks/graphql.ts @@ -116,12 +116,12 @@ export const createSearchQuery = (model: CmsModel) => { entryId savedOn createdOn - createdBy { + revisionCreatedBy { id type displayName } - ownedBy { + createdBy { id type displayName diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx index 8b6e0ab5f3d..88f1890f6e3 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntriesModule.tsx @@ -9,11 +9,17 @@ import { } from "~/admin/components/ContentEntries/BulkActions"; import { FilterByStatus } from "~/admin/components/ContentEntries/Filters"; import { - DeleteEntry, + DeleteEntry as DeleteEntryMenuItem, SaveAndPublishButton, SaveContentButton } from "~/admin/components/ContentEntryForm/Header"; import { DeleteFolder, EditFolder, SetFolderPermissions } from "@webiny/app-aco"; +import { + ChangeEntryStatus, + DeleteEntry, + EditEntry, + MoveEntry +} from "~/admin/components/ContentEntries/Table/Actions"; import { CellActions, CellAuthor, @@ -37,6 +43,10 @@ export const ContentEntriesModule = () => { } /> } /> } /> + } /> + } /> + } /> + } /> { } /> } /> - } /> + } /> ); diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx index 8f4136872f0..1a65bd21646 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx @@ -6,10 +6,7 @@ import { CmsContentEntry } from "~/types"; import { CmsEntryCreateFromMutationResponse, CmsEntryCreateFromMutationVariables, - CmsEntryUnpublishMutationResponse, - CmsEntryUnpublishMutationVariables, - createCreateFromMutation, - createUnpublishMutation + createCreateFromMutation } from "@webiny/app-headless-cms-common"; import { useApolloClient, useCms } from "~/admin/hooks"; import { useContentEntry } from "~/admin/views/contentEntries/hooks/useContentEntry"; @@ -48,7 +45,7 @@ export interface UseRevisionProps { } export const useRevision = ({ revision }: UseRevisionProps) => { - const { publishEntryRevision, deleteEntry } = useCms(); + const { publishEntryRevision, unpublishEntryRevision, deleteEntry } = useCms(); const { contentModel, entry, setLoading } = useContentEntry(); const { history } = useRouter(); @@ -58,10 +55,9 @@ export const useRevision = ({ revision }: UseRevisionProps) => { const { updateRecordInCache } = useRecords(); - const { CREATE_REVISION, UNPUBLISH_REVISION } = useMemo(() => { + const { CREATE_REVISION } = useMemo(() => { return { - CREATE_REVISION: createCreateFromMutation(contentModel), - UNPUBLISH_REVISION: createUnpublishMutation(contentModel) + CREATE_REVISION: createCreateFromMutation(contentModel) }; }, [modelId]); @@ -169,32 +165,27 @@ export const useRevision = ({ revision }: UseRevisionProps) => { ); }, unpublishRevision: - (): UnpublishRevisionHandler => - async (id): Promise => { + ({ entry }): UnpublishRevisionHandler => + async id => { setLoading(true); - const result = await client.mutate< - CmsEntryUnpublishMutationResponse, - CmsEntryUnpublishMutationVariables - >({ - mutation: UNPUBLISH_REVISION, - variables: { - revision: id || revision.id - } + + const response = await unpublishEntryRevision({ + model: contentModel, + entry, + id: id || entry.id }); + setLoading(false); - if (!result || !result.data) { - showSnackbar( - `Missing result in update callback on Unpublish Mutation.` - ); - return; - } - const { error } = result.data.content; + const { error, entry: entryResult } = response; + if (error) { showSnackbar(error.message); return; } + updateRecordInCache(entryResult); + showSnackbar( Successfully unpublished revision{" "} diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx index 0616325df4b..1fe713ccabb 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/hooks/useContentEntriesList.tsx @@ -14,7 +14,6 @@ import { } from "@webiny/app-aco"; import { CMS_ENTRY_LIST_LINK } from "~/admin/constants"; import { FolderTableItem, ListMeta } from "@webiny/app-aco/types"; -import { usePermission } from "~/admin/hooks"; interface UpdateSearchCallableParams { search: string; @@ -26,6 +25,7 @@ interface UpdateSearchCallable { export interface ContentEntriesListProviderContext { folders: FolderTableItem[]; + getEntryEditUrl: (item: EntryTableItem) => string; hideFilters: () => void; isListLoading: boolean; isListLoadingMore: boolean; @@ -34,7 +34,6 @@ export interface ContentEntriesListProviderContext { listTitle?: string; meta: ListMeta; onSelectRow: (rows: TableItem[] | []) => void; - onEditEntry: (item: EntryTableItem) => void; records: EntryTableItem[]; search: string; selected: CmsContentEntry[]; @@ -59,7 +58,6 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi const { history } = useRouter(); const { contentModel } = useContentEntries(); const { currentFolderId } = useNavigateFolder(); - const { canEdit } = usePermission(); const { folders: initialFolders, @@ -131,21 +129,17 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi setSelected(cmsContentEntries); }; - const onEditEntry = useCallback( - (entry: EntryTableItem) => { - if (!canEdit(entry, "cms.contentEntry")) { - return; - } - + const getEntryEditUrl = useCallback( + (entry: EntryTableItem): string => { const folderPath = currentFolderId ? `&folderId=${encodeURIComponent(currentFolderId)}` : ""; const idPath = encodeURIComponent(entry.id); - history.push(`${baseUrl}?id=${idPath}${folderPath}`); + return `${baseUrl}?id=${idPath}${folderPath}`; }, - [canEdit, baseUrl, currentFolderId] + [baseUrl, currentFolderId] ); const records = useMemo(() => { @@ -169,6 +163,7 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi const context: ContentEntriesListProviderContext = { folders, + getEntryEditUrl, isListLoading, isListLoadingMore, isSearch, @@ -176,7 +171,6 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi listMoreRecords, meta, onSelectRow, - onEditEntry, records, search, selected, diff --git a/packages/app-headless-cms/src/admin/views/contentModels/importing/ImportContext.tsx b/packages/app-headless-cms/src/admin/views/contentModels/importing/ImportContext.tsx index a2b527b169f..adf2d19e94d 100644 --- a/packages/app-headless-cms/src/admin/views/contentModels/importing/ImportContext.tsx +++ b/packages/app-headless-cms/src/admin/views/contentModels/importing/ImportContext.tsx @@ -3,6 +3,9 @@ import { useApolloClient } from "~/admin/hooks"; import { IMPORT_STRUCTURE, ImportStructureResponse, + ImportStructureVariables, + ImportStructureVariablesGroup, + ImportStructureVariablesModel, VALIDATE_IMPORT_STRUCTURE, ValidateImportStructureResponse } from "~/admin/views/contentModels/importing/graphql"; @@ -140,11 +143,16 @@ const createSelected = (params: CreateSelectedParams): Selected => { return selected; }; -const getDataToImport = (state: State) => { +interface DataToImportResult { + groups: ImportStructureVariablesGroup[]; + models: ImportStructureVariablesModel[]; +} + +const getDataToImport = (state: State): DataToImportResult => { const selected = Array.from(state.selected.keys()); - const groups: Map = new Map(); - const models: Map = new Map(); + const groups: Map = new Map(); + const models: Map = new Map(); const noAction = [ImportAction.CODE, ImportAction.NONE]; for (const id of selected) { @@ -156,21 +164,28 @@ const getDataToImport = (state: State) => { ) { continue; } + const validatedGroup = state.groups?.find(group => group.id === validatedModel.group); + if (!validatedGroup?.action || validatedGroup.error) { + continue; + } const model = state.data?.models?.find(model => model.modelId === id); if (!model) { continue; } - models.set(id, model); + models.set(id, { + ...model, + layout: model.layout || [], + titleFieldId: model.titleFieldId || "id", + descriptionFieldId: model.descriptionFieldId || "", + imageFieldId: model.imageFieldId || "", + group: validatedModel.group + }); - const validatedGroup = state.groups?.find(group => group.id === validatedModel.group); - if ( - !validatedGroup?.action || - validatedGroup.error || - noAction.includes(validatedGroup.action) - ) { + if (noAction.includes(validatedGroup.action)) { continue; } + const group = state.data?.groups?.find(group => group.id === validatedModel.group); if (!group) { continue; @@ -259,7 +274,7 @@ export const ImportContextProvider = ({ children }: ImportContextProviderProps) return; } setState(prev => { - return { + const next = { ...prev, loading: false, groups: data.groups.map(group => { @@ -282,6 +297,7 @@ export const ImportContextProvider = ({ children }: ImportContextProviderProps) }), validated: true }; + return next; }); }, [state.data, setState]); @@ -310,7 +326,7 @@ export const ImportContextProvider = ({ children }: ImportContextProviderProps) let result: FetchResult | undefined; try { - result = await client.mutate({ + result = await client.mutate({ mutation: IMPORT_STRUCTURE, variables: { data: dataToImport diff --git a/packages/app-headless-cms/src/admin/views/contentModels/importing/graphql.ts b/packages/app-headless-cms/src/admin/views/contentModels/importing/graphql.ts index 37562c1b9fe..7a1f3c72f4d 100644 --- a/packages/app-headless-cms/src/admin/views/contentModels/importing/graphql.ts +++ b/packages/app-headless-cms/src/admin/views/contentModels/importing/graphql.ts @@ -1,5 +1,5 @@ import gql from "graphql-tag"; -import { CmsErrorResponse } from "@webiny/app-headless-cms-common/types"; +import { CmsErrorResponse, CmsModelField } from "@webiny/app-headless-cms-common/types"; import { ImportAction, ValidatedCmsGroup, ValidatedCmsModel } from "./types"; const ERROR = /* GraphQL */ ` @@ -52,6 +52,37 @@ export const VALIDATE_IMPORT_STRUCTURE = gql` } `; +export interface ImportStructureVariablesGroup { + id: string; + name: string; + slug?: string; + description?: string; + icon: string; +} + +export interface ImportStructureVariablesModel { + name: string; + singularApiName: string; + pluralApiName: string; + modelId: string; + group: string; + icon?: string; + description?: string; + layout: string[][]; + fields: CmsModelField[]; + titleFieldId: string; + descriptionFieldId?: string; + imageFieldId?: string; + tags?: string[]; +} + +export interface ImportStructureVariables { + data: { + groups: ImportStructureVariablesGroup[]; + models: ImportStructureVariablesModel[]; + }; +} + export interface ImportStructureResponseDataGroup { group: { id: string; @@ -79,6 +110,7 @@ export interface ImportStructureResponseData { models: ImportStructureResponseDataModel[]; message: string; } + export interface ImportStructureResponse { importStructure: { data: ImportStructureResponseData | null; diff --git a/packages/app-page-builder-elements/package.json b/packages/app-page-builder-elements/package.json index c31fe172bec..ac91db25474 100644 --- a/packages/app-page-builder-elements/package.json +++ b/packages/app-page-builder-elements/package.json @@ -19,9 +19,7 @@ "@emotion/styled": "^11.10.6", "@webiny/lexical-editor": "0.0.0", "@webiny/theme": "0.0.0", - "@webiny/validation": "0.0.0", - "facepaint": "^1.2.1", - "lodash": "^4.17.21" + "facepaint": "^1.2.1" }, "peerDependencies": { "@editorjs/editorjs": "^2.20.1", diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx index 8347afd438f..1db6fa7bb94 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx @@ -34,11 +34,20 @@ export interface FormRenderProps { loading: boolean; } +type FormRedirectTrigger = { + redirect: { + url: string; + }; +}; + const FormRender = (props: FormRenderProps) => { const { formData, createFormParams } = props; const { preview = false, formLayoutComponents = [] } = createFormParams; const [currentStepIndex, setCurrentStepIndex] = useState(0); const [formState, setFormState] = useState(); + const [formRedirectTrigger, setFormRedirectTrigger] = useState( + null + ); // We need to add index to every step so we can properly, // add or remove step from array of steps based on step rules. @@ -128,23 +137,33 @@ const FormRender = (props: FormRenderProps) => { const validateStepConditions = (formData: Record, stepIndex: number) => { const currentStep = resolvedSteps[stepIndex]; - const nextStepIndex = getNextStepIndex({ + const action = getNextStepIndex({ formData, rules: currentStep.rules }); - if (nextStepIndex === "submit") { + if (action.type === "submitAndRedirect") { setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); - } else if (nextStepIndex !== "") { - setModifiedSteps([ - ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(+nextStepIndex) - ]); + setFormRedirectTrigger({ + redirect: { + url: action.value + } + }); } else { - setModifiedSteps([ - ...modifiedSteps.slice(0, stepIndex + 1), - ...steps.slice(currentStep.index + 1) - ]); + setFormRedirectTrigger(null); + if (action.type === "submit") { + setModifiedSteps([...modifiedSteps.slice(0, stepIndex + 1)]); + } else if (action.type === "goToStep") { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(+action.value) + ]); + } else { + setModifiedSteps([ + ...modifiedSteps.slice(0, stepIndex + 1), + ...steps.slice(currentStep.index + 1) + ]); + } } }; @@ -166,7 +185,7 @@ const FormRender = (props: FormRenderProps) => { if (field.settings.rules !== undefined) { field.settings?.rules.forEach((rule: FormRule) => { if (checkIfConditionsMet({ formData: formState, rule })) { - if (rule.action === "show") { + if (rule.action.value === "show") { fieldLayout.splice(fieldIndex, 1, ...field.settings.layout); } else { fieldLayout.splice(fieldIndex, field.settings.layout.length, [ @@ -241,7 +260,6 @@ const FormRender = (props: FormRenderProps) => { }); return { ...values, ...overrides }; }; - const submit = async ( formSubmissionFieldValues: FormSubmissionFieldValues ): Promise => { @@ -267,6 +285,13 @@ const FormRender = (props: FormRenderProps) => { }; } + if (formRedirectTrigger) { + props.formData.triggers = { + ...props.formData.triggers, + ...formRedirectTrigger + }; + } + const formSubmission = await createFormSubmission({ props, formSubmissionFieldValues, diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts index b8bc6c6baf6..82c5dd668de 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender/functions/getNextStepIndex.ts @@ -1,5 +1,4 @@ -import { validation } from "@webiny/validation"; - +import * as validators from "~/validators"; import { FbFormCondition, FbFormRule } from "~/types"; interface Props { @@ -12,7 +11,7 @@ const includesValidator = (filterValue: string, fieldValue: string) => { return; } - return fieldValue.includes(filterValue); + return validators.includes(fieldValue, filterValue); }; const startsWithValidator = (filterValue: string, fieldValue: string) => { @@ -20,14 +19,7 @@ const startsWithValidator = (filterValue: string, fieldValue: string) => { return; } - // Need to use try catch block because without it validation will throw and error, - // so user won't be able to interact with page. - // Same applies to all validation methods below. - try { - return validation.validateSync(fieldValue, `starts:${filterValue}`); - } catch { - return; - } + return validators.startsWith(fieldValue, filterValue); }; const endsWithValidator = (filterValue: string, fieldValue: string) => { @@ -35,11 +27,7 @@ const endsWithValidator = (filterValue: string, fieldValue: string) => { return; } - try { - return validation.validateSync(fieldValue, `ends:${filterValue}`); - } catch { - return; - } + return validators.endsWith(fieldValue, filterValue); }; const isValidator = (filterValue: string, fieldValue: string | string[]) => { @@ -47,16 +35,7 @@ const isValidator = (filterValue: string, fieldValue: string | string[]) => { return; } - try { - // This is check for checkboxes. - if (typeof fieldValue === "object") { - return fieldValue.includes(filterValue); - } else { - return validation.validateSync(fieldValue, `eq:${filterValue}`); - } - } catch { - return; - } + return validators.is(fieldValue, filterValue); }; const gtValidator = ({ @@ -72,14 +51,10 @@ const gtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `gt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `gte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.gt(fieldValue, filterValue); + } else { + return validators.gt(fieldValue, filterValue, true); } }; @@ -96,14 +71,10 @@ const ltValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(fieldValue, `lt:${filterValue}`); - } else { - return validation.validateSync(fieldValue, `lte:${filterValue}`); - } - } catch { - return; + if (!equal) { + return validators.lt(fieldValue, filterValue); + } else { + return validators.lt(fieldValue, filterValue, true); } }; @@ -120,17 +91,10 @@ const timeGtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `gt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `gte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.gt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -147,17 +111,10 @@ const timeLtValidator = ({ return; } - try { - if (!equal) { - return validation.validateSync(Date.parse(fieldValue), `lt:${Date.parse(filterValue)}`); - } else { - return validation.validateSync( - Date.parse(fieldValue), - `lte:${Date.parse(filterValue)}` - ); - } - } catch { - return; + if (!equal) { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue)); + } else { + return validators.lt(Date.parse(fieldValue), Date.parse(filterValue), true); } }; @@ -205,7 +162,7 @@ const checkCondition = (condition: FbFormCondition, fieldValue: string) => { }; export const checkIfConditionsMet = ({ formData, rule }: Props) => { - if (rule.chain === "matchAll") { + if (rule.matchAll) { let isValid = true; rule.conditions.forEach(condition => { @@ -236,13 +193,16 @@ interface GetNextStepIndexProps { } export default ({ formData, rules }: GetNextStepIndexProps) => { - let nextStepIndex = ""; + let action = { + type: "", + value: "" + }; rules.forEach(rule => { if (checkIfConditionsMet({ formData, rule })) { - nextStepIndex = rule.action; + action = rule.action; return; } }); - return nextStepIndex; + return action; }; diff --git a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts index b7ad14e0d8b..bfc6669edd3 100644 --- a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts +++ b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts @@ -28,8 +28,11 @@ export const GET_PUBLISHED_FORM = /* GraphQL */ ` layout rules { title - action - chain + action { + type + value + } + matchAll id conditions { id diff --git a/packages/app-page-builder-elements/src/renderers/form/types.ts b/packages/app-page-builder-elements/src/renderers/form/types.ts index 75e0006ffa1..208b2892279 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -51,9 +51,14 @@ export interface FormDataStep { index: number; } +export type FbFormRuleAction = { + type: string; + value: string; +}; + export interface FormRule { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FormCondition[]; diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index 3fab41f2489..173d8fd0deb 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -146,9 +146,14 @@ export type ElementStylesModifier = (args: { export type LinkComponent = React.ComponentType>; +export type FbFormRuleAction = { + type: string; + value: string; +}; + export type FbFormRule = { - action: string; - chain: string; + action: FbFormRuleAction; + matchAll: boolean; id: string; title: string; conditions: FbFormCondition[]; diff --git a/packages/app-page-builder-elements/src/validators/endsWith.ts b/packages/app-page-builder-elements/src/validators/endsWith.ts new file mode 100644 index 00000000000..49ca8f34cb1 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/endsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const endOfTheString = value.slice(value.length - param.length); + + return endOfTheString === param; +}; diff --git a/packages/app-page-builder-elements/src/validators/gt.ts b/packages/app-page-builder-elements/src/validators/gt.ts new file mode 100644 index 00000000000..4ac87a9f31a --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/gt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value >= param; + } else { + return value > param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/includes.ts b/packages/app-page-builder-elements/src/validators/includes.ts new file mode 100644 index 00000000000..2de844cd8ce --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/includes.ts @@ -0,0 +1,7 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return false; + } + + return value.includes(param); +}; diff --git a/packages/app-page-builder-elements/src/validators/index copy.ts b/packages/app-page-builder-elements/src/validators/index copy.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/index copy.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-page-builder-elements/src/validators/index.ts b/packages/app-page-builder-elements/src/validators/index.ts new file mode 100644 index 00000000000..569c60c2a18 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/index.ts @@ -0,0 +1,8 @@ +import includes from "./includes"; +import startsWith from "./startsWith"; +import endsWith from "./endsWith"; +import is from "./is"; +import gt from "./gt"; +import lt from "./lt"; + +export { includes, startsWith, endsWith, is, gt, lt }; diff --git a/packages/app-page-builder-elements/src/validators/is.ts b/packages/app-page-builder-elements/src/validators/is.ts new file mode 100644 index 00000000000..28591003023 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/is.ts @@ -0,0 +1,11 @@ +export default (value: string | string[], param: string) => { + if (!value || !param) { + return false; + } + + if (typeof param === "object") { + return value.includes(param); + } else { + return value === param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/lt.ts b/packages/app-page-builder-elements/src/validators/lt.ts new file mode 100644 index 00000000000..e6a5c5b668e --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/lt.ts @@ -0,0 +1,11 @@ +export default (value: string | number, param: string | number, equal = false) => { + if (!value || !param) { + return false; + } + + if (equal) { + return value <= param; + } else { + return value < param; + } +}; diff --git a/packages/app-page-builder-elements/src/validators/startsWith.ts b/packages/app-page-builder-elements/src/validators/startsWith.ts new file mode 100644 index 00000000000..9f001a731c3 --- /dev/null +++ b/packages/app-page-builder-elements/src/validators/startsWith.ts @@ -0,0 +1,9 @@ +export default (value: string, param: string) => { + if (!value || !param) { + return; + } + + const startOfString = value.slice(0, param.length); + + return startOfString === param; +}; diff --git a/packages/app-page-builder-elements/tsconfig.build.json b/packages/app-page-builder-elements/tsconfig.build.json index 500a685d91f..58a9bc11a38 100644 --- a/packages/app-page-builder-elements/tsconfig.build.json +++ b/packages/app-page-builder-elements/tsconfig.build.json @@ -3,8 +3,7 @@ "include": ["src"], "references": [ { "path": "../lexical-editor/tsconfig.build.json" }, - { "path": "../theme/tsconfig.build.json" }, - { "path": "../validation/tsconfig.build.json" } + { "path": "../theme/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", diff --git a/packages/app-page-builder-elements/tsconfig.json b/packages/app-page-builder-elements/tsconfig.json index ba1c716d308..33e3a51328c 100644 --- a/packages/app-page-builder-elements/tsconfig.json +++ b/packages/app-page-builder-elements/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["src", "__tests__"], - "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }, { "path": "../validation" }], + "references": [{ "path": "../lexical-editor" }, { "path": "../theme" }], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], "outDir": "./dist", @@ -12,9 +12,7 @@ "@webiny/lexical-editor/*": ["../lexical-editor/src/*"], "@webiny/lexical-editor": ["../lexical-editor/src"], "@webiny/theme/*": ["../theme/src/*"], - "@webiny/theme": ["../theme/src"], - "@webiny/validation/*": ["../validation/src/*"], - "@webiny/validation": ["../validation/src"] + "@webiny/theme": ["../theme/src"] }, "baseUrl": "." } diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/ChangePageStatus.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/ChangePageStatus.tsx new file mode 100644 index 00000000000..e24665643de --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/ChangePageStatus.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { ReactComponent as Publish } from "@material-design-icons/svg/outlined/publish.svg"; +import { ReactComponent as Unpublish } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { usePage } from "~/admin/views/Pages/hooks/usePage"; +import { useChangePageStatus } from "~/admin/views/Pages/hooks/useChangePageStatus"; +import { usePagesPermissions } from "~/hooks/permissions"; + +export const ChangePageStatus = () => { + const { page } = usePage(); + const { openDialogUnpublishPage, openDialogPublishPage } = useChangePageStatus({ page }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + const { hasPermissions, canPublish, canUnpublish } = usePagesPermissions(); + + if (!hasPermissions()) { + return null; + } + + if (page.data.status === "published" && canUnpublish()) { + return ( + } + label={"Unpublish"} + onAction={openDialogUnpublishPage} + data-testid={"aco.actions.pb.page.unpublish"} + /> + ); + } + + if (canPublish()) { + return ( + } + label={"Publish"} + onAction={openDialogPublishPage} + data-testid={"aco.actions.pb.page.publish"} + /> + ); + } + + return null; +}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/DeletePage.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/DeletePage.tsx new file mode 100644 index 00000000000..ea7fdb6964b --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/DeletePage.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { usePage } from "~/admin/views/Pages/hooks/usePage"; +import { useDeletePage } from "~/admin/views/Pages/hooks/useDeletePage"; +import { usePagesPermissions } from "~/hooks/permissions"; + +export const DeletePage = () => { + const { page } = usePage(); + const { canDelete } = usePagesPermissions(); + const { openDialogDeletePage } = useDeletePage({ page }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + if (!canDelete(page.data.createdBy.id)) { + return null; + } + + return ( + } + label={"Delete"} + onAction={openDialogDeletePage} + data-testid={"aco.actions.pb.page.delete"} + /> + ); +}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx new file mode 100644 index 00000000000..b7c432cafac --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/EditPage.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { usePage } from "~/admin/views/Pages/hooks/usePage"; +import { useCreatePageFrom } from "~/admin/views/Pages/hooks/useCreatePageFrom"; +import { useNavigatePage } from "~/admin/hooks/useNavigatePage"; +import { usePagesPermissions } from "~/hooks/permissions"; + +export const EditPage = () => { + const { page } = usePage(); + const { canUpdate } = usePagesPermissions(); + const { OptionsMenuItem, OptionsMenuLink } = AcoConfig.Record.Action; + const { getPageEditorUrl, navigateToPageEditor } = useNavigatePage(); + const { createPageForm, loading } = useCreatePageFrom({ + page, + onSuccess: () => navigateToPageEditor(page.data.pid) + }); + + if (!canUpdate(page.data.createdBy.id)) { + return null; + } + + if (page.data.locked) { + return ( + } + label={"Edit"} + onAction={createPageForm} + disabled={loading} + data-testid={"aco.actions.pb.page.edit"} + /> + ); + } + + return ( + } + label={"Edit"} + to={getPageEditorUrl(page.id)} + data-testid={"aco.actions.pb.page.edit"} + /> + ); +}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/MovePage.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/MovePage.tsx new file mode 100644 index 00000000000..bfac8edbde6 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/MovePage.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { usePage } from "~/admin/views/Pages/hooks/usePage"; +import { useMovePageToFolder } from "~/admin/views/Pages/hooks/useMovePageToFolder"; + +export const MovePage = () => { + const { page } = usePage(); + const movePageToFolder = useMovePageToFolder({ record: page }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + return ( + } + label={"Move"} + onAction={movePageToFolder} + data-testid={"aco.actions.pb.page.move"} + /> + ); +}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/PreviewPage.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Actions/PreviewPage.tsx new file mode 100644 index 00000000000..ae2ccb5fa24 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/PreviewPage.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { ReactComponent as Visibility } from "@material-design-icons/svg/outlined/visibility.svg"; +import { AcoConfig } from "@webiny/app-aco"; +import { usePage } from "~/admin/views/Pages/hooks/usePage"; +import { usePreviewPage } from "~/admin/views/Pages/hooks/usePreviewPage"; + +export const PreviewPage = () => { + const { page } = usePage(); + const { previewPage } = usePreviewPage({ page }); + const { OptionsMenuItem } = AcoConfig.Record.Action; + + const label = page.data.status === "published" ? "View" : "Preview"; + + return ( + } + label={label} + onAction={previewPage} + data-testid={"aco.actions.pb.page.preview"} + /> + ); +}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Actions/index.ts b/packages/app-page-builder/src/admin/components/Table/Table/Actions/index.ts new file mode 100644 index 00000000000..f480c1a2acd --- /dev/null +++ b/packages/app-page-builder/src/admin/components/Table/Table/Actions/index.ts @@ -0,0 +1,5 @@ +export * from "./ChangePageStatus"; +export * from "./DeletePage"; +export * from "./EditPage"; +export * from "./MovePage"; +export * from "./PreviewPage"; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Cells/CellActions.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Cells/CellActions.tsx index a2a4987516e..2c58c11fd93 100644 --- a/packages/app-page-builder/src/admin/components/Table/Table/Cells/CellActions.tsx +++ b/packages/app-page-builder/src/admin/components/Table/Table/Cells/CellActions.tsx @@ -1,24 +1,13 @@ import React from "react"; - import { FolderProvider, useAcoConfig } from "@webiny/app-aco"; import { OptionsMenu } from "@webiny/app-admin"; -import { IconButton } from "@webiny/ui/Button"; -import { ReactComponent as MoreIcon } from "@material-design-icons/svg/filled/more_vert.svg"; -import { Menu } from "@webiny/ui/Menu"; - import { PageListConfig } from "~/admin/config/pages"; -import { RecordActionPreview } from "~/admin/components/Table/Table/Row/Record/RecordActionPreview"; -import { RecordActionPublish } from "~/admin/components/Table/Table/Row/Record/RecordActionPublish"; -import { RecordActionEdit } from "~/admin/components/Table/Table/Row/Record/RecordActionEdit"; -import { RecordActionMove } from "~/admin/components/Table/Table/Row/Record/RecordActionMove"; -import { RecordActionDelete } from "~/admin/components/Table/Table/Row/Record/RecordActionDelete"; - -import { menuStyles } from "./Cells.styled"; +import { PageProvider } from "~/admin/views/Pages/hooks/usePage"; export const CellActions = () => { const { useTableRow, isFolderRow } = PageListConfig.Browser.Table.Column; const { row } = useTableRow(); - const { folder: folderConfig } = useAcoConfig(); + const { folder: folderConfig, record: recordConfig } = useAcoConfig(); if (isFolderRow(row)) { // If the user cannot manage folder structure, no need to show the menu. @@ -37,12 +26,11 @@ export const CellActions = () => { } return ( - } />}> - - - - - - + + + ); }; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Cells/Cells.styled.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Cells/Cells.styled.tsx index 10eab5ebe23..8b472745de9 100644 --- a/packages/app-page-builder/src/admin/components/Table/Table/Cells/Cells.styled.tsx +++ b/packages/app-page-builder/src/admin/components/Table/Table/Cells/Cells.styled.tsx @@ -1,5 +1,4 @@ import styled from "@emotion/styled"; -import { css } from "emotion"; import { Typography } from "@webiny/ui/Typography"; export const RowTitle = styled("div")` @@ -18,7 +17,3 @@ export const RowText = styled(Typography)` overflow: hidden; text-overflow: ellipsis; `; - -export const menuStyles = css(` - width: 200px; -`); diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Folder/FolderActionManagePermissions.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Folder/FolderActionManagePermissions.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionDelete.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionDelete.tsx deleted file mode 100644 index ae8d30eee5d..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionDelete.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { ReactElement } from "react"; - -import { ReactComponent as Delete } from "@material-design-icons/svg/outlined/delete.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; - -import { usePagesPermissions } from "~/hooks/permissions"; -import { useDeletePage } from "~/admin/views/Pages/hooks/useDeletePage"; - -import { ListItemGraphic } from "~/admin/components/Table/Table/styled"; -import { PbPageTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/app-page-builder/pages-table/actions/page/delete"); - -interface Props { - record: PbPageTableItem; -} -export const RecordActionDelete = ({ record }: Props): ReactElement => { - const { canDelete } = usePagesPermissions(); - const { openDialogDeletePage } = useDeletePage({ page: record }); - - if (!canDelete(record.data.createdBy.id)) { - console.log("Does not have permission to delete page."); - return <>; - } - - return ( - - - } /> - - {t`Delete`} - - ); -}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionEdit.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionEdit.tsx deleted file mode 100644 index 94a4eeaf994..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionEdit.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { ReactElement, useCallback, useState } from "react"; -import { useMutation } from "@apollo/react-hooks"; -import { ReactComponent as Edit } from "@material-design-icons/svg/outlined/edit.svg"; -import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { CREATE_PAGE } from "~/admin/graphql/pages"; -import * as GQLCache from "~/admin/views/Pages/cache"; -import { usePagesPermissions } from "~/hooks/permissions"; -import { ListItemGraphic } from "~/admin/components/Table/Table/styled"; -import { PbPageTableItem } from "~/types"; -import { useNavigatePage } from "~/admin/hooks/useNavigatePage"; - -const t = i18n.ns("app-headless-cms/app-page-builder/pages-table/actions/page/edit"); - -interface Props { - record: PbPageTableItem; -} -export const RecordActionEdit = ({ record }: Props): ReactElement => { - const { canUpdate } = usePagesPermissions(); - const [inProgress, setInProgress] = useState(); - const { showSnackbar } = useSnackbar(); - const [createPageFrom] = useMutation(CREATE_PAGE); - const { navigateToPageEditor } = useNavigatePage(); - - const createFromAndEdit = useCallback(async () => { - setInProgress(true); - const response = await createPageFrom({ - variables: { from: record.id }, - update(cache, { data }) { - if (data.pageBuilder.createPage.error) { - return; - } - - GQLCache.updateLatestRevisionInListCache(cache, data.pageBuilder.createPage.data); - } - }); - setInProgress(false); - const { data, error } = response.data.pageBuilder.createPage; - if (error) { - return showSnackbar(error.message); - } - - navigateToPageEditor(data.id); - }, [record, navigateToPageEditor]); - - if (!canUpdate(record.data.createdBy.id)) { - return <>; - } - - if (record.data.locked) { - return ( - - - } /> - - {t`Edit`} - - ); - } - return ( - { - navigateToPageEditor(record.id); - }} - > - - } /> - - {t`Edit`} - - ); -}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionMove.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionMove.tsx deleted file mode 100644 index 3baa80bf7ef..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionMove.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { ReactElement } from "react"; - -import { ReactComponent as Move } from "@material-design-icons/svg/outlined/drive_file_move.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; -import { useMovePageToFolder } from "~/admin/views/Pages/hooks/useMovePageToFolder"; - -import { ListItemGraphic } from "~/admin/components/Table/Table/styled"; - -import { PbPageTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/app-page-builder/pages-table/actions/page/move"); - -interface Props { - record: PbPageTableItem; -} - -export const RecordActionMove = ({ record }: Props): ReactElement => { - const movePageToFolder = useMovePageToFolder({ record }); - - return ( - - - } /> - - {t`Move`} - - ); -}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPreview.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPreview.tsx deleted file mode 100644 index a087fee80f3..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPreview.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { ReactElement, useCallback } from "react"; - -import { ReactComponent as Visibility } from "@material-design-icons/svg/outlined/visibility.svg"; -import { i18n } from "@webiny/app/i18n"; -import { Icon } from "@webiny/ui/Icon"; -import { MenuItem } from "@webiny/ui/Menu"; - -import { useConfigureWebsiteUrlDialog } from "~/admin/hooks/useConfigureWebsiteUrl"; -import { usePageBuilderSettings } from "~/admin/hooks/usePageBuilderSettings"; -import { useSiteStatus } from "~/admin/hooks/useSiteStatus"; - -import { ListItemGraphic } from "~/admin/components/Table/Table/styled"; -import { PbPageTableItem } from "~/types"; - -const t = i18n.ns("app-headless-cms/app-page-builder/pages-table/actions/page/preview"); - -interface Props { - record: PbPageTableItem; -} - -export const RecordActionPreview = ({ record }: Props): ReactElement => { - const { getPageUrl, getWebsiteUrl } = usePageBuilderSettings(); - - const [isSiteRunning, refreshSiteStatus] = useSiteStatus(getWebsiteUrl()); - const { showConfigureWebsiteUrlDialog } = useConfigureWebsiteUrlDialog( - getWebsiteUrl(), - refreshSiteStatus - ); - - // We must prevent opening in new tab - Cypress doesn't work with new tabs. - const target = "Cypress" in window ? "_self" : "_blank"; - - const url = getPageUrl(record.data); - - const handlePreviewClick = useCallback(() => { - if (isSiteRunning) { - window.open(url, target, "noopener"); - } else { - showConfigureWebsiteUrlDialog(); - } - }, [url, isSiteRunning]); - - const previewButtonLabel = record.data.status === "published" ? t`View` : t`Preview`; - - return ( - - - } /> - - {previewButtonLabel} - - ); -}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPublish.tsx b/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPublish.tsx deleted file mode 100644 index c8d53f6cbc4..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/Row/Record/RecordActionPublish.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { ReactElement } from "react"; -import { ReactComponent as Publish } from "@material-design-icons/svg/outlined/publish.svg"; -import { ReactComponent as Restore } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; -import { useConfirmationDialog } from "@webiny/app-admin"; -import { i18n } from "@webiny/app/i18n"; -import { MenuItem } from "@webiny/ui/Menu"; - -import { usePagesPermissions } from "~/hooks/permissions"; -import { usePublishRevisionHandler } from "~/admin/plugins/pageDetails/pageRevisions/usePublishRevisionHandler"; - -import { PbPageTableItem } from "~/types"; -import { Icon } from "@webiny/ui/Icon"; -import { ListItemGraphic } from "~/admin/components/Table/Table/styled"; - -const t = i18n.ns("app-headless-cms/app-page-builder/pages-table/actions/page/publish"); - -interface Props { - record: PbPageTableItem; -} - -export const RecordActionPublish = ({ record }: Props): ReactElement => { - const { canPublish, canUnpublish } = usePagesPermissions(); - const { publishRevision, unpublishRevision } = usePublishRevisionHandler(); - - const { showConfirmation: showPublishConfirmation } = useConfirmationDialog({ - title: t`Publish page`, - message: ( -

- {t`You are about to publish the {title} page. Are you sure you want to continue?`({ - title: {record.title} - })} -

- ) - }); - - const { showConfirmation: showUnpublishConfirmation } = useConfirmationDialog({ - title: t`Unpublish page`, - message: ( -

- {t`You are about to unpublish the {title} page. Are you sure you want to continue?`( - { - title: {record.title} - } - )} -

- ) - }); - - const { hasPermissions } = usePagesPermissions(); - if (!hasPermissions()) { - return <>; - } - - if (record.data.status === "published" && canUnpublish()) { - return ( - - showUnpublishConfirmation(async () => { - await unpublishRevision(record.data); - }) - } - > - - } /> - - {t`Unpublish`} - - ); - } - - if (canPublish()) { - return ( - - showPublishConfirmation(async () => { - await publishRevision(record.data); - }) - } - > - - } /> - - {t`Publish`} - - ); - } - - return <>; -}; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/index.ts b/packages/app-page-builder/src/admin/components/Table/Table/index.ts index a705c077474..75cd35bda14 100644 --- a/packages/app-page-builder/src/admin/components/Table/Table/index.ts +++ b/packages/app-page-builder/src/admin/components/Table/Table/index.ts @@ -1,2 +1,3 @@ +export * from "./Actions"; export * from "./Cells"; export * from "./Table"; diff --git a/packages/app-page-builder/src/admin/components/Table/Table/styled.tsx b/packages/app-page-builder/src/admin/components/Table/Table/styled.tsx deleted file mode 100644 index b6f3a25e506..00000000000 --- a/packages/app-page-builder/src/admin/components/Table/Table/styled.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import styled from "@emotion/styled"; -import { css } from "emotion"; - -import { ListItemGraphic as ListItemGraphicBase } from "@webiny/ui/List"; - -export const ListItemGraphic = styled(ListItemGraphicBase)` - margin-right: 25px; -`; - -export const menuStyles = css(` - width: 200px; -`); diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx new file mode 100644 index 00000000000..2ada83aee1e --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { CompositionScope } from "@webiny/react-composition"; +import { AcoConfig, RecordActionConfig } from "@webiny/app-aco"; + +const { Record } = AcoConfig; + +export { RecordActionConfig as PageActionConfig }; + +type PageActionProps = React.ComponentProps; + +export const PageAction = (props: PageActionProps) => { + return ( + + + + + + ); +}; diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx index 633bd005894..b1b18fec746 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx @@ -7,7 +7,9 @@ const { Table } = AcoConfig; export { ColumnConfig }; -const BaseColumn: React.FC> = props => { +type ColumnProps = React.ComponentProps; + +const BaseColumn = (props: ColumnProps) => { return ( diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts b/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts index f10862a1aa2..25a65bdfd4e 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts @@ -1,15 +1,18 @@ import { BulkAction, BulkActionConfig } from "./BulkAction"; import { FolderAction, FolderActionConfig } from "./FolderAction"; +import { PageAction, PageActionConfig } from "./PageAction"; import { Table, TableConfig } from "./Table"; export interface BrowserConfig { bulkActions: BulkActionConfig[]; folderActions: FolderActionConfig[]; + pageActions: PageActionConfig[]; table: TableConfig; } export const Browser = { BulkAction, FolderAction, + PageAction, Table }; diff --git a/packages/app-page-builder/src/admin/hooks/useNavigatePage.ts b/packages/app-page-builder/src/admin/hooks/useNavigatePage.ts index 4f20a0eca08..3c536fba9d9 100644 --- a/packages/app-page-builder/src/admin/hooks/useNavigatePage.ts +++ b/packages/app-page-builder/src/admin/hooks/useNavigatePage.ts @@ -6,6 +6,7 @@ import { PAGE_BUILDER_EDITOR_LINK, PAGE_BUILDER_LIST_LINK } from "~/admin/consta interface UseNavigatePageResponse { navigateToLatestFolder: () => void; navigateToListHome: () => void; + getPageEditorUrl: (id: string) => string; navigateToPageEditor: (id: string) => void; } @@ -21,15 +22,17 @@ export const useNavigatePage = () => { }, [folderId]); return useMemo(() => { + const getPageEditorUrl = (id: string) => { + return `${PAGE_BUILDER_EDITOR_LINK}/${encodeURIComponent(id)}${folderUrl}`; + }; const navigateToPageEditor = (id: string) => { - return history.push( - `${PAGE_BUILDER_EDITOR_LINK}/${encodeURIComponent(id)}${folderUrl}` - ); + return history.push(getPageEditorUrl(id)); }; if (navigateFolder) { return { navigateToLatestFolder: navigateFolder.navigateToLatestFolder, navigateToListHome: navigateFolder.navigateToListHome, + getPageEditorUrl, navigateToPageEditor }; } @@ -40,6 +43,7 @@ export const useNavigatePage = () => { navigateToListHome: () => { return history.push(PAGE_BUILDER_LIST_LINK); }, + getPageEditorUrl, navigateToPageEditor }; }, [navigateFolder, params]); diff --git a/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx b/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx index 6b325e32784..10e5cfd9bdd 100644 --- a/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx @@ -14,7 +14,12 @@ import { CellAuthor, CellModified, CellName, - CellStatus + CellStatus, + ChangePageStatus, + DeletePage, + EditPage, + MovePage, + PreviewPage } from "~/admin/components/Table/Table"; const { Browser } = PageListConfig; @@ -30,6 +35,11 @@ export const PagesModule = () => { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> { + const { publishRevision, unpublishRevision } = usePublishRevisionHandler(); + + const { showConfirmation: showPublishConfirmation } = useConfirmationDialog({ + title: "Publish page", + message: `You are about to publish the "${page.data.title}" page. Are you sure you want to continue?` + }); + + const { showConfirmation: showUnpublishConfirmation } = useConfirmationDialog({ + title: "Unpublish page", + message: `You are about to unpublish the "${page.data.title}" page. Are you sure you want to continue?` + }); + + const openDialogPublishPage = useCallback( + () => + showPublishConfirmation(async () => { + await publishRevision(page.data); + }), + [page] + ); + + const openDialogUnpublishPage = useCallback( + () => + showUnpublishConfirmation(async () => { + await unpublishRevision(page.data); + }), + [page] + ); + + return { + openDialogPublishPage, + openDialogUnpublishPage + }; +}; diff --git a/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts b/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts new file mode 100644 index 00000000000..62807809a7f --- /dev/null +++ b/packages/app-page-builder/src/admin/views/Pages/hooks/useCreatePageFrom.ts @@ -0,0 +1,47 @@ +import { useCallback, useState } from "react"; +import { useMutation } from "@apollo/react-hooks"; +import { useSnackbar } from "@webiny/app-admin"; +import { CREATE_PAGE } from "~/admin/graphql/pages"; +import * as GQLCache from "~/admin/views/Pages/cache"; + +import { PbPageTableItem } from "~/types"; + +interface UseEditPageParams { + page: PbPageTableItem; + onSuccess?: () => void; +} + +export const useCreatePageFrom = ({ page, onSuccess }: UseEditPageParams) => { + const [loading, setLoading] = useState(); + const [createPageFrom] = useMutation(CREATE_PAGE); + const { showSnackbar } = useSnackbar(); + + const createPageForm = useCallback(async () => { + setLoading(true); + const response = await createPageFrom({ + variables: { from: page.id }, + update(cache, { data }) { + if (data.pageBuilder.createPage.error) { + return; + } + + GQLCache.updateLatestRevisionInListCache(cache, data.pageBuilder.createPage.data); + } + }); + setLoading(false); + + const { error } = response.data.pageBuilder.createPage; + if (error) { + return showSnackbar(error.message); + } + + if (typeof onSuccess === "function") { + onSuccess(); + } + }, [page, onSuccess]); + + return { + createPageForm, + loading + }; +}; diff --git a/packages/app-page-builder/src/admin/views/Pages/hooks/usePage.tsx b/packages/app-page-builder/src/admin/views/Pages/hooks/usePage.tsx new file mode 100644 index 00000000000..ef944fc4090 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/Pages/hooks/usePage.tsx @@ -0,0 +1,30 @@ +import React, { createContext } from "react"; +import { PbPageTableItem } from "~/types"; + +export interface PageContext { + page: PbPageTableItem; +} + +export const PageContext = createContext(undefined); + +interface PageProviderProps { + page: PbPageTableItem; + children: React.ReactNode; +} + +export const PageProvider = ({ page, children }: PageProviderProps) => { + const value: PageContext = { page }; + + return {children}; +}; + +export const usePage = () => { + const context = React.useContext(PageContext); + if (!context) { + throw Error( + `PageContext is missing in the component tree. Are you using "usePage()" hook in the right place?` + ); + } + + return context; +}; diff --git a/packages/app-page-builder/src/admin/views/Pages/hooks/usePreviewPage.ts b/packages/app-page-builder/src/admin/views/Pages/hooks/usePreviewPage.ts new file mode 100644 index 00000000000..3827a4f3efb --- /dev/null +++ b/packages/app-page-builder/src/admin/views/Pages/hooks/usePreviewPage.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { usePageBuilderSettings } from "~/admin/hooks/usePageBuilderSettings"; +import { useSiteStatus } from "~/admin/hooks/useSiteStatus"; +import { useConfigureWebsiteUrlDialog } from "~/admin/hooks/useConfigureWebsiteUrl"; +import { PbPageTableItem } from "~/types"; + +interface UsePreviewPageParams { + page: PbPageTableItem; +} + +export const usePreviewPage = ({ page }: UsePreviewPageParams) => { + const { getPageUrl, getWebsiteUrl } = usePageBuilderSettings(); + const [isSiteRunning, refreshSiteStatus] = useSiteStatus(getWebsiteUrl()); + + const { showConfigureWebsiteUrlDialog } = useConfigureWebsiteUrlDialog( + getWebsiteUrl(), + refreshSiteStatus + ); + + // We must prevent opening in new tab - Cypress doesn't work with new tabs. + const target = "Cypress" in window ? "_self" : "_blank"; + + const url = getPageUrl(page.data); + + const previewPage = useCallback(() => { + if (isSiteRunning) { + window.open(url, target, "noopener"); + } else { + showConfigureWebsiteUrlDialog(); + } + }, [url, isSiteRunning]); + + return { + previewPage + }; +}; diff --git a/packages/app-page-builder/src/components.ts b/packages/app-page-builder/src/components.ts new file mode 100644 index 00000000000..e93ba1ae67f --- /dev/null +++ b/packages/app-page-builder/src/components.ts @@ -0,0 +1,27 @@ +import PagePublishRevision from "./admin/plugins/pageDetails/header/publishRevision/PublishRevision"; +import { PublishPageMenuOption } from "./admin/plugins/pageDetails/pageRevisions/PublishPageMenuOption"; +import { PageRevisionListItemGraphic } from "./admin/plugins/pageDetails/pageRevisions/PageRevisionListItemGraphic"; +import { PublishPageButton } from "./pageEditor"; + +export const Components = { + PageDetails: { + /** + * This component is used in the page details drawer, which opens from the pages table. + */ + PublishPageRevision: PagePublishRevision, + /** + * This component is used in the page revisions list, within the page details drawer. + */ + PublishPageMenuOption, + /** + * This component is used in the list of revisions, located in the page details drawer. + */ + PageRevisionListItemGraphic + }, + PageEditor: { + /** + * This component is used in the main page editor. + */ + PublishPageButton + } +}; diff --git a/packages/app-page-builder/src/index.ts b/packages/app-page-builder/src/index.ts index 94543bff2c8..311b75ac234 100644 --- a/packages/app-page-builder/src/index.ts +++ b/packages/app-page-builder/src/index.ts @@ -6,3 +6,5 @@ export * from "./modules/WebsiteSettings/AddPbWebsiteSettings"; export * from "./plugins"; export { LexicalEditorConfig } from "~/editor/lexicalConfig/LexicalEditorConfig"; + +export * from "./components"; diff --git a/packages/app-serverless-cms/src/apolloClientFactory.ts b/packages/app-serverless-cms/src/apolloClientFactory.ts index 0ea9aecdaae..f1715bbd8b9 100644 --- a/packages/app-serverless-cms/src/apolloClientFactory.ts +++ b/packages/app-serverless-cms/src/apolloClientFactory.ts @@ -19,8 +19,9 @@ export const createApolloClient = ({ uri, batching }: CreateApolloClientParams) new ApolloDynamicLink(), /** * This batches requests made to the API to pack multiple requests into a single HTTP request. + * `credentials: "include"` is necessary to attach cookies to requests. */ - new BatchHttpLink({ uri, ...batching }) + new BatchHttpLink({ uri, credentials: "include", ...batching }) ]), cache: new InMemoryCache({ addTypename: true, diff --git a/packages/aws-layers/index.d.ts b/packages/aws-layers/index.d.ts new file mode 100644 index 00000000000..832538c6ff4 --- /dev/null +++ b/packages/aws-layers/index.d.ts @@ -0,0 +1 @@ +export declare function getLayerArn(name: string, region?: string): string; diff --git a/packages/aws-layers/package.json b/packages/aws-layers/package.json index 95fffd19f7e..a43c2b79b5b 100644 --- a/packages/aws-layers/package.json +++ b/packages/aws-layers/package.json @@ -2,6 +2,7 @@ "name": "@webiny/aws-layers", "version": "0.0.0", "main": "index.js", + "types": "./index.d.ts", "repository": { "type": "git", "url": "https://github.com/webiny/webiny-js.git", diff --git a/packages/aws-sdk/package.json b/packages/aws-sdk/package.json index 1e9db0713e0..8bc67416802 100644 --- a/packages/aws-sdk/package.json +++ b/packages/aws-sdk/package.json @@ -16,6 +16,7 @@ "@aws-sdk/client-iam": "^3.425.0", "@aws-sdk/client-lambda": "^3.425.0", "@aws-sdk/client-s3": "^3.425.0", + "@aws-sdk/client-sfn": "^3.425.0", "@aws-sdk/client-sqs": "^3.425.0", "@aws-sdk/client-sts": "^3.425.0", "@aws-sdk/credential-providers": "^3.425.0", diff --git a/packages/aws-sdk/src/client-eventbridge/index.ts b/packages/aws-sdk/src/client-eventbridge/index.ts index 9423abd4b3e..9e92e8a44bf 100644 --- a/packages/aws-sdk/src/client-eventbridge/index.ts +++ b/packages/aws-sdk/src/client-eventbridge/index.ts @@ -1,5 +1,8 @@ export { EventBridgeClient, PutEventsRequestEntry, - PutEventsCommand + PutEventsCommand, + PutEventsCommandInput } from "@aws-sdk/client-eventbridge"; + +export * from "./types"; diff --git a/packages/aws-sdk/src/client-eventbridge/types.ts b/packages/aws-sdk/src/client-eventbridge/types.ts new file mode 100644 index 00000000000..645d811ea18 --- /dev/null +++ b/packages/aws-sdk/src/client-eventbridge/types.ts @@ -0,0 +1,31 @@ +export interface EventBridgeClientSendResponseEntry { + EventId: string; +} + +export interface EventBridgeClientSendResponse { + $metadata: { + httpStatusCode: number; + requestId: string; + attempts: number; + totalRetryDelay: number; + }; + Entries: EventBridgeClientSendResponseEntry[]; + FailedEntryCount: number; +} + +/** + * This is what the event looks like when it's received from EventBridge. + */ +export interface IIncomingEventBridgeEvent { + version: `${number}`; + id: string; + "detail-type": string; + source: string; + account: string; + time: string; + region: string; + resources: unknown[]; + detail: { + [key: string]: any; + }; +} diff --git a/packages/aws-sdk/src/client-sfn/index.ts b/packages/aws-sdk/src/client-sfn/index.ts new file mode 100644 index 00000000000..3fc8f003a84 --- /dev/null +++ b/packages/aws-sdk/src/client-sfn/index.ts @@ -0,0 +1,45 @@ +import { + SFNClient, + SFNClientConfig, + StartExecutionCommand, + StartExecutionCommandInput +} from "@aws-sdk/client-sfn"; + +export { SFNClient, StartExecutionCommand, SFNServiceException } from "@aws-sdk/client-sfn"; + +export type GenericData = string | number | boolean | null | undefined; + +export interface GenericStepFunctionData { + [key: string]: GenericData | GenericData[]; +} + +export interface TriggerStepFunctionParams< + T extends GenericStepFunctionData = GenericStepFunctionData +> extends Partial> { + input: T; +} + +const getClient = (config: SFNClient | SFNClientConfig): SFNClient => { + if (config instanceof SFNClient) { + return config; + } + return new SFNClient({ + ...config, + region: config.region || process.env.AWS_REGION + }); +}; + +export const triggerStepFunctionFactory = (config: SFNClient | SFNClientConfig) => { + const client = getClient(config); + return async ( + params: TriggerStepFunctionParams + ) => { + const cmd = new StartExecutionCommand({ + ...params, + stateMachineArn: params.stateMachineArn || process.env.BG_TASK_SFN_ARN, + name: params.name, + input: JSON.stringify(params.input) + }); + return await client.send(cmd); + }; +}; diff --git a/packages/cli-plugin-deploy-pulumi/commands/deploy.js b/packages/cli-plugin-deploy-pulumi/commands/deploy.js index eb2324475d1..f4dce804cb1 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/deploy.js +++ b/packages/cli-plugin-deploy-pulumi/commands/deploy.js @@ -106,9 +106,9 @@ module.exports = (params, context) => { const duration = getDuration(); if (inputs.preview) { - context.success(`Done! Preview finished in ${context.success.hl(duration)}.`); + context.success(`Done! Preview finished in %s.`, duration); } else { - context.success(`Done! Deploy finished in ${context.success.hl(duration)}.`); + context.success(`Done! Deploy finished in %s.`, duration); } console.log(); diff --git a/packages/cli-plugin-deploy-pulumi/commands/deploy/buildPackages.js b/packages/cli-plugin-deploy-pulumi/commands/deploy/buildPackages.js index 07bf0a004f0..bdea402127e 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/deploy/buildPackages.js +++ b/packages/cli-plugin-deploy-pulumi/commands/deploy/buildPackages.js @@ -23,15 +23,11 @@ module.exports = async ({ projectApplication, inputs, context }) => { context.info(`No packages to build...`); return; case 1: - context.info( - `Building ${context.info.hl(projectApplication.packages[0].name)} package...` - ); + context.info(`Building %s package...`, projectApplication.packages[0].name); break; default: multipleBuilds = true; - context.info( - `Building ${context.info.hl(projectApplication.packages.length)} packages...` - ); + context.info(`Building %s packages...`, projectApplication.packages.length); break; } @@ -104,7 +100,7 @@ module.exports = async ({ projectApplication, inputs, context }) => { if (multipleBuilds) { stats.success++; const duration = (new Date() - start) / 1000 + "s"; - context.success(`${current.name} (${context.success.hl(duration)})`); + context.success(`%s (%s)`, current.name, duration); } return resolve({ @@ -119,9 +115,8 @@ module.exports = async ({ projectApplication, inputs, context }) => { worker.on("error", () => { stats.error++; context.error( - `An unknown error occurred while building ${context.error.hl( - current.name - )} package.` + `An unknown error occurred while building %s package.`, + current.name ); resolve({ @@ -138,11 +133,7 @@ module.exports = async ({ projectApplication, inputs, context }) => { } stats.error++; - context.error( - `An error occurred while building ${context.error.hl( - current.name - )} package.` - ); + context.error(`An error occurred while building %s package.`, current.name); resolve({ package: current, @@ -171,15 +162,15 @@ module.exports = async ({ projectApplication, inputs, context }) => { if (multipleBuilds) { context.success( - `Successfully built ${context.success.hl( - projectApplication.packages.length - )} packages in ${context.success.hl(duration)}.` + `Successfully built %s packages in %s.`, + projectApplication.packages.length, + duration ); } else { context.success( - `Successfully built ${context.success.hl( - projectApplication.packages[0].name - )} in ${context.success.hl(duration)}.` + `Successfully built %s in %s.`, + projectApplication.packages[0].name, + duration ); } }; diff --git a/packages/cli-plugin-deploy-pulumi/commands/destroy.js b/packages/cli-plugin-deploy-pulumi/commands/destroy.js index 79b6ee67fb8..dce078d75cd 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/destroy.js +++ b/packages/cli-plugin-deploy-pulumi/commands/destroy.js @@ -29,11 +29,7 @@ module.exports = createPulumiCommand({ } if (!stackExists) { - context.error( - `Project application ${context.error.hl(folder)} (${context.error.hl( - env - )} environment) does not exist.` - ); + context.error(`Project application %s (%s} environment) does not exist.`, folder, env); return; } @@ -58,8 +54,7 @@ module.exports = createPulumiCommand({ console.log(); - const duration = getDuration(); - context.success(`Done! Destroy finished in ${context.success.hl(duration + "s")}.`); + context.success(`Done! Destroy finished in %s.`, getDuration()); await processHooks("hook-after-destroy", hooksParams); } diff --git a/packages/cli-plugin-deploy-pulumi/commands/output.js b/packages/cli-plugin-deploy-pulumi/commands/output.js index 6ca9f859a8a..7a734cd54ff 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/output.js +++ b/packages/cli-plugin-deploy-pulumi/commands/output.js @@ -40,10 +40,6 @@ module.exports = createPulumiCommand({ return console.log(JSON.stringify(null)); } - context.error( - `Project application ${context.error.hl(folder)} (${context.error.hl( - env - )} environment) does not exist.` - ); + context.error(`Project application %s (%s environment) does not exist.`, folder, env); } }); diff --git a/packages/cli-plugin-deploy-pulumi/commands/pulumiRun.js b/packages/cli-plugin-deploy-pulumi/commands/pulumiRun.js index 5520b355033..cadd9714b6b 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/pulumiRun.js +++ b/packages/cli-plugin-deploy-pulumi/commands/pulumiRun.js @@ -8,10 +8,7 @@ module.exports = createPulumiCommand({ const { env, folder, debug, variant } = inputs; if (env) { - debug && - context.debug( - `Environment provided - selecting ${context.debug.hl(env)} Pulumi stack.` - ); + debug && context.debug(`Environment provided - selecting %s Pulumi stack.`, env); let stackExists = true; try { @@ -43,12 +40,11 @@ module.exports = createPulumiCommand({ } if (debug) { - const pulumiCommand = `${context.debug.hl("pulumi " + command.join(" "))}`; debug && context.debug( - `Running the following command in ${context.debug.hl( - folder - )} folder: ${pulumiCommand}` + `Running the following command in %s folder: %s`, + folder, + "pulumi " + command.join(" ") ); } diff --git a/packages/cli-plugin-deploy-pulumi/commands/watch/output/browserOutput.js b/packages/cli-plugin-deploy-pulumi/commands/watch/output/browserOutput.js index e3e64c3a786..5608348a87b 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/watch/output/browserOutput.js +++ b/packages/cli-plugin-deploy-pulumi/commands/watch/output/browserOutput.js @@ -38,7 +38,7 @@ module.exports = { server.httpServer.listen(port, () => { const destination = "http://localhost:" + port; - log.success(`Development server started at ${log.success.hl(destination)}.`); + log.success(`Development server started at %s.`, destination); setTimeout(() => { if (!connected) { open(destination); diff --git a/packages/cli/commands/telemetry/index.js b/packages/cli/commands/telemetry/index.js index 1ff542bf476..7870435b5b8 100644 --- a/packages/cli/commands/telemetry/index.js +++ b/packages/cli/commands/telemetry/index.js @@ -8,9 +8,8 @@ module.exports = { telemetry.enable(); await telemetry.sendEvent({ event: "enable-telemetry" }); context.info( - `Webiny telemetry is now ${context.info.hl( - "enabled" - )}! Thank you for helping us in making Webiny better!` + `Webiny telemetry is now %s! Thank you for helping us in making Webiny better!`, + "enabled" ); context.info( `For more information, please visit the following link: https://www.webiny.com/telemetry.` @@ -20,11 +19,10 @@ module.exports = { yargs.command("disable-telemetry", "Disable anonymous telemetry.", async () => { await telemetry.sendEvent({ event: "disable-telemetry" }); telemetry.disable(); - context.info(`Webiny telemetry is now ${context.info.hl("disabled")}!`); + context.info(`Webiny telemetry is now %s!`, "disabled"); context.info( - `Note that, in order to complete the process, you will also need to re-deploy your project, using the ${context.info.hl( - "yarn webiny deploy" - )} command.` + `Note that, in order to complete the process, you will also need to re-deploy your project, using the %s command.`, + "yarn webiny deploy" ); }); } diff --git a/packages/cli/commands/wcp/login.js b/packages/cli/commands/wcp/login.js index 0e2681a4ca7..ecf22712c61 100644 --- a/packages/cli/commands/wcp/login.js +++ b/packages/cli/commands/wcp/login.js @@ -95,9 +95,8 @@ module.exports.command = () => ({ } catch (e) { if (debug) { context.debug( - `Could not use the provided ${context.debug.hl( - patFromParams - )} PAT because of the following error:` + `Could not use the provided %s PAT because of the following error:`, + patFromParams ); console.debug(e); } @@ -118,7 +117,7 @@ module.exports.command = () => ({ )}&ref=cli`; const openUrl = `${getWcpAppUrl()}/login/cli?${queryParams}`; - debug && context.debug(`Opening ${context.debug.hl(openUrl)}...`); + debug && context.debug(`Opening %s...`, openUrl); await open(openUrl); const graphql = { diff --git a/packages/create-webiny-project/bin.js b/packages/create-webiny-project/bin.js index 8e92263cdbb..809008ba8f0 100755 --- a/packages/create-webiny-project/bin.js +++ b/packages/create-webiny-project/bin.js @@ -4,15 +4,22 @@ const semver = require("semver"); const chalk = require("chalk"); const getYarnVersion = require("./utils/getYarnVersion"); +const getNpmVersion = require("./utils/getNpmVersion"); const verifyConfig = require("./utils/verifyConfig"); (async () => { + const minNodeVersion = "16"; + const minNpmVersion = "10"; + const minYarnVersion = "1.22.21"; + /** + * Node + */ const nodeVersion = process.versions.node; - if (!semver.satisfies(nodeVersion, ">=14")) { + if (!semver.satisfies(nodeVersion, `>=${minNodeVersion}`)) { console.error( chalk.red( [ - `You are running Node.js ${nodeVersion}, but Webiny requires version 14 or higher.`, + `You are running Node.js ${nodeVersion}, but Webiny requires version ${minNodeVersion} or higher.`, `Please switch to one of the required versions and try again.`, "For more information, please visit https://docs.webiny.com/docs/tutorials/install-webiny#prerequisites." ].join(" ") @@ -20,14 +27,46 @@ const verifyConfig = require("./utils/verifyConfig"); ); process.exit(1); } + /** + * npm + */ + try { + const npmVersion = await getNpmVersion(); + if (!semver.satisfies(npmVersion, `>=${minNpmVersion}`)) { + console.error( + chalk.red( + [ + `Webiny requires npm@^${minNpmVersion} or higher.`, + `Please run ${chalk.green( + "npm install npm@latest -g" + )}, to get the latest version.` + ].join("\n") + ) + ); + process.exit(1); + } + } catch (err) { + console.error(chalk.red(`Webiny depends on "npm".`)); + + console.log( + `Please visit https://docs.npmjs.com/try-the-latest-stable-version-of-npm to install ${chalk.green( + "npm" + )}.` + ); + + process.exit(1); + } + /** + * yarn + */ try { const yarnVersion = await getYarnVersion(); - if (!semver.satisfies(yarnVersion, ">=1.22.0")) { + if (!semver.satisfies(yarnVersion, `>=${minYarnVersion}`)) { console.error( chalk.red( [ - `Webiny requires yarn@^1.22.0 or higher.`, + `Webiny requires yarn@^${minYarnVersion} or higher.`, `Please visit https://yarnpkg.com/ to install ${chalk.green("yarn")}.` ].join("\n") ) diff --git a/packages/create-webiny-project/utils/createProject.js b/packages/create-webiny-project/utils/createProject.js index 03c8968d701..d37f823fccf 100644 --- a/packages/create-webiny-project/utils/createProject.js +++ b/packages/create-webiny-project/utils/createProject.js @@ -140,10 +140,6 @@ module.exports = async function createProject({ const target = path.join(projectRoot, yarnReleasesFilePath); fs.copyFileSync(source, target); - await execa("yarn", ["set", "version", yarnVersion], { - cwd: projectRoot - }); - const yamlPath = path.join(projectRoot, ".yarnrc.yml"); if (!fs.existsSync(yamlPath)) { fs.writeFileSync(yamlPath, `yarnPath: ${yarnReleasesFilePath}`, "utf-8"); @@ -272,6 +268,18 @@ module.exports = async function createProject({ const node = process.versions.node; const os = process.platform; + let npm = NOT_APPLICABLE; + try { + const subprocess = await execa("npm", ["--version"], { cwd: projectRoot }); + npm = subprocess.stdout; + } catch {} + + let npx = NOT_APPLICABLE; + try { + const subprocess = await execa("npx", ["--version"], { cwd: projectRoot }); + npx = subprocess.stdout; + } catch {} + let yarn = NOT_APPLICABLE; try { const subprocess = await execa("yarn", ["--version"], { cwd: projectRoot }); @@ -312,6 +320,8 @@ module.exports = async function createProject({ `Operating System: ${os}`, `Node: ${node}`, `Yarn: ${yarn}`, + `Npm: ${npm}`, + `Npx: ${npx}`, `create-webiny-project: ${cwp}`, `Template: ${cwpTemplate}`, `Template Options: ${templateOptionsJson}`, diff --git a/packages/create-webiny-project/utils/getNpmVersion.js b/packages/create-webiny-project/utils/getNpmVersion.js new file mode 100644 index 00000000000..b207efe26e5 --- /dev/null +++ b/packages/create-webiny-project/utils/getNpmVersion.js @@ -0,0 +1,10 @@ +const execa = require("execa"); + +module.exports = async () => { + try { + const { stdout } = await execa("npm", ["--version"]); + return stdout; + } catch (err) { + return ""; + } +}; diff --git a/packages/cwp-template-aws/template/common/tsconfig.build.json b/packages/cwp-template-aws/template/common/tsconfig.build.json index 5a5798675b0..ed928afe114 100644 --- a/packages/cwp-template-aws/template/common/tsconfig.build.json +++ b/packages/cwp-template-aws/template/common/tsconfig.build.json @@ -1,53 +1,7 @@ { + "extends": "@webiny/project-utils/configs/tsconfig.build.json", "compilerOptions": { - "target": "es6", - "allowJs": true, - "forceConsistentCasingInFileNames": true, - "allowSyntheticDefaultImports": true, - "moduleResolution": "node", - "module": "esnext", - "lib": ["esnext", "dom", "dom.iterable"], - "esModuleInterop": true, - "declaration": true, - "composite": true, - "noEmit": false, - "jsx": "preserve", - "emitDeclarationOnly": true, - "baseUrl": ".", - "paths": {}, - "typeRoots": ["node_modules/@types", "./types"], - "noUnusedParameters": false, - "noUnusedLocals": false, - "noImplicitUseStrict": false, - "noImplicitThis": true, - "noImplicitReturns": true, - "noImplicitAny": true, - "noUncheckedIndexedAccess": false, - "noStrictGenericChecks": false, - "noFallthroughCasesInSwitch": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "noImplicitOverride": true, - "strict": true, - "noPropertyAccessFromIndexSignature": true, - "suppressImplicitAnyIndexErrors": false, - "suppressExcessPropertyErrors": false, - "keyofStringsOnly": false, - "experimentalDecorators": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - /** - * If you set to true there would need to be A LOT of error handling changes. - * https://www.typescriptlang.org/tsconfig#useUnknownInCatchVariables - */ - "useUnknownInCatchVariables": false, - /** - * Setting to true will start producing TS errors that are inside libraries we use. - * https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes - */ - "exactOptionalPropertyTypes": false + "typeRoots": ["node_modules/@types", "./types"] }, "exclude": ["node_modules", "dist", "**jest**", "**resources.js"] } diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json index 286bed1545d..af68b8e47f8 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/package.json @@ -10,6 +10,7 @@ "@webiny/api-apw": "latest", "@webiny/api-apw-scheduler-so-ddb": "latest", "@webiny/api-audit-logs": "latest", + "@webiny/api-background-tasks-es": "latest", "@webiny/api-file-manager": "latest", "@webiny/api-file-manager-ddb": "latest", "@webiny/api-file-manager-s3": "latest", diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts index 781ae150326..fcddb67b7cb 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/index.ts @@ -33,6 +33,7 @@ import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; +import { createBackgroundTasks } from "@webiny/api-background-tasks-es"; /** * APW */ @@ -74,6 +75,7 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ documentClient diff --git a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json index 286bed1545d..25986e06fb6 100644 --- a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/package.json @@ -10,6 +10,7 @@ "@webiny/api-apw": "latest", "@webiny/api-apw-scheduler-so-ddb": "latest", "@webiny/api-audit-logs": "latest", + "@webiny/api-background-tasks-os": "latest", "@webiny/api-file-manager": "latest", "@webiny/api-file-manager-ddb": "latest", "@webiny/api-file-manager-s3": "latest", diff --git a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts index 781ae150326..cc4c3f80a7e 100644 --- a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/index.ts @@ -23,7 +23,7 @@ import elasticsearchClientContext, { import { createFileManagerContext, createFileManagerGraphQL } from "@webiny/api-file-manager"; import { createFileManagerStorageOperations } from "@webiny/api-file-manager-ddb"; import logsPlugins from "@webiny/handler-logs"; -import fileManagerS3 from "@webiny/api-file-manager-s3"; +import fileManagerS3, { createAssetDelivery } from "@webiny/api-file-manager-s3"; import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb-es"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; @@ -33,6 +33,7 @@ import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; +import { createBackgroundTasks } from "@webiny/api-background-tasks-os"; /** * APW */ @@ -74,12 +75,14 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ documentClient }) }), createFileManagerGraphQL(), + createAssetDelivery({ documentClient }), fileManagerS3(), prerenderingServicePlugins({ eventBus: String(process.env.EVENT_BUS) diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json b/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json index dd1985e71e8..316231e6289 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/package.json @@ -10,6 +10,7 @@ "@webiny/api-apw": "latest", "@webiny/api-apw-scheduler-so-ddb": "latest", "@webiny/api-audit-logs": "latest", + "@webiny/api-background-tasks-ddb": "latest", "@webiny/api-file-manager": "latest", "@webiny/api-file-manager-ddb": "latest", "@webiny/api-file-manager-s3": "latest", diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts index 73bcb5608d5..e15ed1494cc 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/index.ts @@ -29,6 +29,7 @@ import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; import { createAuditLogs } from "@webiny/api-audit-logs"; +import { createBackgroundTasks } from "@webiny/api-background-tasks-ddb"; /** * APW */ @@ -63,6 +64,7 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ documentClient diff --git a/packages/db-dynamodb/src/utils/batchWrite.ts b/packages/db-dynamodb/src/utils/batchWrite.ts index bb4664d17b5..b65e5afe94a 100644 --- a/packages/db-dynamodb/src/utils/batchWrite.ts +++ b/packages/db-dynamodb/src/utils/batchWrite.ts @@ -11,21 +11,74 @@ export interface BatchWriteParams { items: BatchWriteItem[]; } +export interface BatchWriteResponse { + next?: () => Promise; + $metadata: { + httpStatusCode: number; + requestId: string; + attempts: number; + totalRetryDelay: number; + }; + UnprocessedItems?: { + [table: string]: WriteRequest[]; + }; +} + +export type BatchWriteResult = BatchWriteResponse[]; + +const hasUnprocessedItems = (result: BatchWriteResponse): boolean => { + if (typeof result.next !== "function") { + return false; + } + const items = result.UnprocessedItems; + if (!items || typeof items !== "object") { + return false; + } + const keys = Object.keys(items); + return keys.some(key => { + const value = items[key]; + if (!Array.isArray(value)) { + return false; + } + return value.some(val => { + return val.PutRequest || val.DeleteRequest; + }); + }); +}; + +const retry = async (input: BatchWriteResponse, results: BatchWriteResult): Promise => { + if (!hasUnprocessedItems(input)) { + return; + } + const result = await input.next!(); + await retry(result, results); +}; /** * Method is meant for batch writing to a single table. * It expects already prepared items for writing. * It can either delete or put items * The method does not check items before actually sending them into the underlying library. */ -export const batchWriteAll = async (params: BatchWriteParams, maxChunk = 25): Promise => { +export const batchWriteAll = async ( + params: BatchWriteParams, + maxChunk = 25 +): Promise => { const { items: collection, table } = params; - if (collection.length === 0 || !table) { - return; + if (!table) { + console.log("No table provided."); + return []; + } else if (collection.length === 0) { + return []; } + const chunkedItems: BatchWriteItem[][] = lodashChunk(collection, maxChunk); + const results: BatchWriteResult = []; for (const items of chunkedItems) { - await table.batchWrite(items, { + const result = (await table.batchWrite(items, { execute: true - }); + })) as BatchWriteResponse; + results.push(result); + await retry(result, results); } + return results; }; diff --git a/packages/db-dynamodb/src/utils/scan.ts b/packages/db-dynamodb/src/utils/scan.ts index f81bed97344..48c9f6244f4 100644 --- a/packages/db-dynamodb/src/utils/scan.ts +++ b/packages/db-dynamodb/src/utils/scan.ts @@ -1,6 +1,8 @@ import { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb"; import { Entity, ScanOptions, Table } from "~/toolbox"; +export type { ScanOptions }; + export interface BaseScanParams { options?: ScanOptions; params?: Partial; @@ -18,7 +20,7 @@ export interface ScanWithEntity extends BaseScanParams { export type ScanParams = ScanWithTable | ScanWithEntity; -export interface ScanResponse { +export interface ScanResponse { items: T[]; count?: number; scannedCount?: number; diff --git a/packages/form/src/BindPrefix.tsx b/packages/form/src/BindPrefix.tsx new file mode 100644 index 00000000000..a96c8efec62 --- /dev/null +++ b/packages/form/src/BindPrefix.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +const BindPrefixContext = React.createContext(undefined); + +export interface BindPrefixProps { + name: string; + children: React.ReactNode; +} + +export function BindPrefix({ children, name }: BindPrefixProps) { + return {children}; +} + +export function useBindPrefix() { + return React.useContext(BindPrefixContext); +} diff --git a/packages/form/src/Form.tsx b/packages/form/src/Form.tsx index 8e5733b97b1..998bb832be7 100644 --- a/packages/form/src/Form.tsx +++ b/packages/form/src/Form.tsx @@ -27,6 +27,7 @@ import { } from "~/types"; import { Validator } from "@webiny/validation/types"; import camelCase from "lodash/camelCase"; +import { useBindPrefix } from "~/BindPrefix"; interface State { data: T; @@ -57,15 +58,18 @@ export const useForm = () => { export function useBind(props: BindComponentProps): UseBindHook { const form = useForm(); + const bindPrefix = useBindPrefix(); + + const bindName = [bindPrefix, props.name].filter(Boolean).join("."); useEffect(() => { - if (props.defaultValue !== undefined && lodashGet(form.data, props.name) === undefined) { - form.setValue(props.name, props.defaultValue); + if (props.defaultValue !== undefined && lodashGet(form.data, bindName) === undefined) { + form.setValue(bindName, props.defaultValue); } }, []); // @ts-expect-error - return form.createField(props); + return form.createField({ ...props, name: bindName }); } interface InputRecord { @@ -236,6 +240,9 @@ function FormInner( }); useImperativeHandle(ref, () => ({ + validate: () => { + return formRef.current.validate(); + }, submit: (ev: React.SyntheticEvent, options?: FormSubmitOptions) => { /** * We need to `return` to utilize the `props.onSubmit` return value. It's useful for plugins and chaining diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts index 9b233cccf45..af23978aea7 100644 --- a/packages/form/src/index.ts +++ b/packages/form/src/index.ts @@ -1,3 +1,4 @@ export * from "./Form"; export * from "./Bind"; +export * from "./BindPrefix"; export * from "./types"; diff --git a/packages/handler-aws/src/execute.ts b/packages/handler-aws/src/execute.ts index 7b6b277cf7b..fa8d82f5a9d 100644 --- a/packages/handler-aws/src/execute.ts +++ b/packages/handler-aws/src/execute.ts @@ -36,7 +36,7 @@ const getPayloadProperty = ( prop: string, defaults: Record = {} ): Record => { - if (typeof payload === "object") { + if (payload && typeof payload === "object") { const value = payload[prop] ? payload[prop] : {}; return { diff --git a/packages/handler-aws/src/index.ts b/packages/handler-aws/src/index.ts index 1cae3142939..6e6e4f3f26a 100644 --- a/packages/handler-aws/src/index.ts +++ b/packages/handler-aws/src/index.ts @@ -94,3 +94,4 @@ export { export { ContextPlugin, createContextPlugin, ContextPluginCallable } from "@webiny/handler"; export * from "./createHandler"; +export * from "./sourceHandler"; diff --git a/packages/handler-aws/src/sourceHandler.ts b/packages/handler-aws/src/sourceHandler.ts index 0423e94adb6..74c539ce7ae 100644 --- a/packages/handler-aws/src/sourceHandler.ts +++ b/packages/handler-aws/src/sourceHandler.ts @@ -1,8 +1,8 @@ import { SourceHandler, HandlerEvent, HandlerFactoryParams } from "~/types"; export const createSourceHandler = < - TEvent extends HandlerEvent, - TParams extends HandlerFactoryParams + TEvent = HandlerEvent, + TParams extends HandlerFactoryParams = HandlerFactoryParams >( handler: SourceHandler ) => { diff --git a/packages/handler-aws/src/types.ts b/packages/handler-aws/src/types.ts index e45097d6093..1cbe0f0a2b6 100644 --- a/packages/handler-aws/src/types.ts +++ b/packages/handler-aws/src/types.ts @@ -45,12 +45,12 @@ export interface HandlerParams { } export interface SourceHandler< - E extends HandlerEvent = HandlerEvent, + E = HandlerEvent, P extends HandlerFactoryParams = HandlerFactoryParams, T = any > { name: string; - canUse: (event: E, context: LambdaContext) => boolean; + canUse: (event: Partial, context: LambdaContext) => boolean; handle: (params: HandlerParams) => Promise; } diff --git a/packages/handler/__tests__/headers.test.ts b/packages/handler/__tests__/headers.test.ts new file mode 100644 index 00000000000..86895317c16 --- /dev/null +++ b/packages/handler/__tests__/headers.test.ts @@ -0,0 +1,74 @@ +import { ResponseHeaders } from "~/ResponseHeaders"; +import { createHandler } from "~/fastify"; +import { createRoute } from "~/plugins/RoutePlugin"; +import { createModifyResponseHeaders } from "~/plugins/ModifyResponseHeadersPlugin"; + +const createOptionsRoute = () => { + return createRoute(({ onOptions }) => { + onOptions("/webiny-test", async (_, reply) => { + return reply.send({ + weGotToOptionsReply: true + }); + }); + }); +}; + +describe("ResponseHeaders class", () => { + it("should provide a type safe way of modifying headers", async () => { + const headers = ResponseHeaders.create(); + + headers.set("access-control-allow-headers", value => { + return [value, "x-wby-custom", "x-tenant", "x-i18n-locale"].filter(Boolean).join(","); + }); + + headers.set("x-webiny-version", value => value); + + expect(headers.getHeaders()).toEqual({ + "access-control-allow-headers": "x-wby-custom,x-tenant,x-i18n-locale", + "x-tenant": undefined + }); + }); + + it("should provide a way to modify response headers through plugins", async () => { + const app = createHandler({ + plugins: [ + createOptionsRoute(), + createModifyResponseHeaders((_, headers) => { + headers.set( + "access-control-allow-methods", + () => "OPTIONS,POST,GET,DELETE,PUT,PATCH" + ); + }), + createModifyResponseHeaders((_, headers) => { + headers.set("x-custom", "custom-header"); + headers.set("cache-control", "public, max-age=86400"); + headers.set("access-control-max-age", "86400"); + }) + ] + }); + + const result = await app.inject({ + path: "/webiny-test", + method: "OPTIONS", + query: {}, + payload: JSON.stringify({}) + }); + + expect(result).toMatchObject({ + statusCode: 204, + cookies: [], + headers: { + "cache-control": "public, max-age=86400", + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "OPTIONS,POST,GET,DELETE,PUT,PATCH", + "access-control-max-age": "86400", + connection: "keep-alive", + "x-custom": "custom-header" + }, + body: "", + payload: "" + }); + }); +}); diff --git a/packages/handler/src/ResponseHeaders.ts b/packages/handler/src/ResponseHeaders.ts new file mode 100644 index 00000000000..4734642c816 --- /dev/null +++ b/packages/handler/src/ResponseHeaders.ts @@ -0,0 +1,64 @@ +import * as http from "http"; + +type ExtraHeaders = { + "content-type"?: string | undefined; + "x-webiny-version"?: http.OutgoingHttpHeader | undefined; +}; + +type AllHeaders = http.OutgoingHttpHeaders & ExtraHeaders; + +export type StandardHeaderValue = http.OutgoingHttpHeader | boolean | undefined; + +// Extract known standard headers, and remove all non-string keys. +export type StandardHeaders = { + [K in keyof AllHeaders as string extends K + ? never + : number extends K + ? never + : K]: http.OutgoingHttpHeaders[K]; +} & { + [name: string]: StandardHeaderValue; +}; + +function isFunction(setter: unknown): setter is (value: T) => T { + return typeof setter === "function"; +} + +type Setter = ((value: T) => T) | T; + +export class ResponseHeaders { + private readonly headers = new Map(); + + private constructor(initialHeaders?: StandardHeaders) { + if (initialHeaders) { + (Object.keys(initialHeaders) as Array).forEach(key => { + this.headers.set(key, initialHeaders[key]); + }); + } + } + + set(header: T, setter: Setter) { + if (isFunction(setter)) { + const previousValue = this.headers.get(header) as StandardHeaders[T]; + const newValue = setter(previousValue); + this.headers.set(header, newValue); + return this; + } + + this.headers.set(header, setter); + + return this; + } + + merge(headers: ResponseHeaders) { + return ResponseHeaders.create({ ...this.getHeaders(), ...headers.getHeaders() }); + } + + getHeaders() { + return Object.fromEntries(this.headers); + } + + static create(initialHeaders?: StandardHeaders) { + return new ResponseHeaders(initialHeaders); + } +} diff --git a/packages/handler/src/fastify.ts b/packages/handler/src/fastify.ts index 4cc75f6e19b..feb06dc2754 100644 --- a/packages/handler/src/fastify.ts +++ b/packages/handler/src/fastify.ts @@ -18,40 +18,52 @@ import { HandlerResultPlugin } from "./plugins/HandlerResultPlugin"; import { HandlerErrorPlugin } from "./plugins/HandlerErrorPlugin"; import { ModifyFastifyPlugin } from "~/plugins/ModifyFastifyPlugin"; import { HandlerOnRequestPlugin } from "~/plugins/HandlerOnRequestPlugin"; +import { ResponseHeaders } from "~/ResponseHeaders"; +import { ModifyResponseHeadersPlugin } from "~/plugins/ModifyResponseHeadersPlugin"; -const DEFAULT_HEADERS: Record = { - "Cache-Control": "no-store", - "Content-Type": "application/json; charset=utf-8", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*", - "Access-Control-Allow-Methods": "OPTIONS,POST,GET,DELETE,PUT,PATCH", - ...getWebinyVersionHeaders() +function createDefaultHeaders() { + return ResponseHeaders.create({ + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "OPTIONS,POST,GET,DELETE,PUT,PATCH", + ...getWebinyVersionHeaders() + }); +} + +const getDefaultOptionsHeaders = () => { + return ResponseHeaders.create({ + "access-control-max-age": "86400", + "cache-control": "public, max-age=86400" + }); }; -const getDefaultHeaders = (routes: DefinedContextRoutes): Record => { +const getDefaultHeaders = (routes: DefinedContextRoutes): ResponseHeaders => { + const headers = createDefaultHeaders(); + /** * If we are accepting all headers, just output that one. */ const keys = Object.keys(routes) as HTTPMethods[]; const all = keys.every(key => routes[key].length > 0); if (all) { - return { - ...DEFAULT_HEADERS, - "Access-Control-Allow-Methods": "*" - }; - } - return { - ...DEFAULT_HEADERS, - "Access-Control-Allow-Methods": keys + headers.set("access-control-allow-methods", "*"); + } else { + const allowedMethods = keys .filter(type => { - if (!routes[type] || Array.isArray(routes[type]) === false) { + if (!routes[type] || !Array.isArray(routes[type])) { return false; } return routes[type].length > 0; }) .sort() - .join(",") - }; + .join(","); + + headers.set("access-control-allow-methods", allowedMethods); + } + + return headers; }; interface CustomError extends Error { @@ -72,11 +84,6 @@ const stringifyError = (error: CustomError) => { }); }; -const OPTIONS_HEADERS: Record = { - "Access-Control-Max-Age": "86400", - "Cache-Control": "public, max-age=86400" -}; - export interface CreateHandlerParams { plugins: PluginCollection | PluginsContainer; options?: ServerOptions; @@ -266,47 +273,22 @@ export const createHandler = (params: CreateHandlerParams) => { */ app.decorate("webiny", context); - /** - * We have few types of triggers: - * * Events - EventPlugin - * * Routes - RoutePlugin - * - * Routes are registered in fastify but events must be handled in package which implements cloud specific methods. - */ - const routePlugins = app.webiny.plugins.byType(RoutePlugin.type); - - /** - * Add routes to the system. - */ - let routePluginName: string | undefined; - try { - for (const plugin of routePlugins) { - routePluginName = plugin.name; - plugin.cb({ - ...app.webiny.routes, - context: app.webiny - }); - } - } catch (ex) { - console.error( - `Error while running the "RoutePlugin" ${ - routePluginName ? `(${routePluginName})` : "" - } plugin in the beginning of the "createHandler" callable.` - ); - console.error(stringifyError(ex)); - throw ex; - } - /** * On every request we add default headers, which can be changed later. * Also, if it is an options request, we skip everything after this hook and output options headers. */ app.addHook("onRequest", async (request, reply) => { + const isOptionsRequest = request.method === "OPTIONS"; /** * Our default headers are always set. Users can override them. */ const defaultHeaders = getDefaultHeaders(definedRoutes); - reply.headers(defaultHeaders); + + const initialHeaders = isOptionsRequest + ? defaultHeaders.merge(getDefaultOptionsHeaders()) + : defaultHeaders; + + reply.headers(initialHeaders.getHeaders()); /** * Users can define their own custom handlers for the onRequest event - so let's run them first. */ @@ -337,7 +319,7 @@ export const createHandler = (params: CreateHandlerParams) => { * * Users can prevent this by creating their own HandlerOnRequestPlugin and returning false as the result of the callable. */ - if (request.method !== "OPTIONS") { + if (!isOptionsRequest) { return; } @@ -355,11 +337,7 @@ export const createHandler = (params: CreateHandlerParams) => { return; } - reply - .headers({ ...defaultHeaders, ...OPTIONS_HEADERS }) - .code(204) - .send("") - .hijack(); + reply.code(204).send("").hijack(); }); app.addHook("preParsing", async (request, reply) => { @@ -482,12 +460,33 @@ export const createHandler = (params: CreateHandlerParams) => { return reply; }); + + /** + * Apply response headers modifier plugins. + */ + app.addHook("onSend", async (request, reply, payload) => { + const modifyHeaders = app.webiny.plugins.byType( + ModifyResponseHeadersPlugin.type + ); + + const headers = ResponseHeaders.create(reply.getHeaders()); + + modifyHeaders.forEach(plugin => { + plugin.modify(request, headers); + }); + + reply.headers(headers.getHeaders()); + + return payload; + }); + /** * We need to output the benchmark results at the end of the request in both response and timeout cases */ app.addHook("onResponse", async () => { await context.benchmark.output(); }); + app.addHook("onTimeout", async () => { await context.benchmark.output(); }); @@ -513,5 +512,36 @@ export const createHandler = (params: CreateHandlerParams) => { throw ex; } + /** + * We have few types of triggers: + * * Events - EventPlugin + * * Routes - RoutePlugin + * + * Routes are registered in fastify but events must be handled in package which implements cloud specific methods. + */ + const routePlugins = app.webiny.plugins.byType(RoutePlugin.type); + + /** + * Add routes to the system. + */ + let routePluginName: string | undefined; + try { + for (const plugin of routePlugins) { + routePluginName = plugin.name; + plugin.cb({ + ...app.webiny.routes, + context: app.webiny + }); + } + } catch (ex) { + console.error( + `Error while running the "RoutePlugin" ${ + routePluginName ? `(${routePluginName})` : "" + } plugin in the beginning of the "createHandler" callable.` + ); + console.error(stringifyError(ex)); + throw ex; + } + return app; }; diff --git a/packages/handler/src/index.ts b/packages/handler/src/index.ts index 75f342fc02c..fe330ff0b59 100644 --- a/packages/handler/src/index.ts +++ b/packages/handler/src/index.ts @@ -1,5 +1,6 @@ export * from "~/fastify"; export * from "~/Context"; +export * from "~/ResponseHeaders"; export * from "~/plugins/EventPlugin"; export * from "~/plugins/RoutePlugin"; export * from "~/plugins/BeforeHandlerPlugin"; @@ -7,3 +8,5 @@ export * from "~/plugins/HandlerErrorPlugin"; export * from "~/plugins/HandlerResultPlugin"; export * from "~/plugins/HandlerOnRequestPlugin"; export * from "~/plugins/ModifyFastifyPlugin"; +export * from "~/plugins/ModifyResponseHeadersPlugin"; +export * from "./ResponseHeaders"; diff --git a/packages/handler/src/plugins/ModifyResponseHeadersPlugin.ts b/packages/handler/src/plugins/ModifyResponseHeadersPlugin.ts new file mode 100644 index 00000000000..e9abf1b7341 --- /dev/null +++ b/packages/handler/src/plugins/ModifyResponseHeadersPlugin.ts @@ -0,0 +1,25 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { ResponseHeaders } from "~/ResponseHeaders"; +import { Request } from "~/types"; + +interface ModifyResponseHeadersCallable { + (request: Request, headers: ResponseHeaders): void; +} + +export class ModifyResponseHeadersPlugin extends Plugin { + public static override type = "handler.response.modifyHeaders"; + private readonly cb: ModifyResponseHeadersCallable; + + constructor(cb: ModifyResponseHeadersCallable) { + super(); + this.cb = cb; + } + + modify(request: Request, headers: ResponseHeaders) { + this.cb(request, headers); + } +} + +export function createModifyResponseHeaders(cb: ModifyResponseHeadersCallable) { + return new ModifyResponseHeadersPlugin(cb); +} diff --git a/packages/handler/src/types.ts b/packages/handler/src/types.ts index 0a0bf27a0f9..de5381d83d3 100644 --- a/packages/handler/src/types.ts +++ b/packages/handler/src/types.ts @@ -1,5 +1,5 @@ +import "@fastify/cookie"; import { FastifyRequest, FastifyReply, HTTPMethods, RouteHandlerMethod } from "fastify"; - export { FastifyInstance, HTTPMethods } from "fastify"; import { ClientContext } from "@webiny/handler-client/types"; diff --git a/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts b/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts index 45c54a568ac..20ad6eb4eaa 100644 --- a/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts +++ b/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts @@ -332,7 +332,6 @@ describe("5.36.0-001", () => { // Should force-run the migration { - // @ts-expect-error process.env["WEBINY_MIGRATION_FORCE_EXECUTE_5_36_0_001"] = "true"; process.stdout.write("[Second run]\n"); const { data, error } = await handler(); diff --git a/packages/migrations/src/ddb-es.ts b/packages/migrations/src/ddb-es.ts index 473366da319..9dd9299ad93 100644 --- a/packages/migrations/src/ddb-es.ts +++ b/packages/migrations/src/ddb-es.ts @@ -13,10 +13,13 @@ import { CmsEntriesRootFolder_5_37_0_002 } from "~/migrations/5.37.0/002/ddb-es" import { AcoFolders_5_37_0_003 } from "~/migrations/5.37.0/003/ddb-es"; import { AcoRecords_5_37_0_004 } from "~/migrations/5.37.0/004/ddb-es"; import { FileManager_5_37_0_005 } from "~/migrations/5.37.0/005/ddb-es"; +// 5.38.0 import { MultiStepForms_5_38_0_001 } from "~/migrations/5.38.0/001/ddb-es"; import { MultiStepForms_5_38_0_002 } from "~/migrations/5.38.0/002/ddb-es"; // Page Blocks storage is the same for both DDB abd DDB-ES projects. import { PageBlocks_5_38_0_003 } from "~/migrations/5.38.0/003/ddb"; +// 5.39.0 +import { FileManager_5_39_0_005 } from "~/migrations/5.39.0/005/ddb-es"; export const migrations = () => { return [ @@ -34,6 +37,7 @@ export const migrations = () => { FileManager_5_37_0_005, MultiStepForms_5_38_0_001, MultiStepForms_5_38_0_002, - PageBlocks_5_38_0_003 + PageBlocks_5_38_0_003, + FileManager_5_39_0_005 ]; }; diff --git a/packages/migrations/src/ddb.ts b/packages/migrations/src/ddb.ts index b3d3bdd479a..2d3f2c25227 100644 --- a/packages/migrations/src/ddb.ts +++ b/packages/migrations/src/ddb.ts @@ -13,9 +13,12 @@ import { CmsEntriesRootFolder_5_37_0_002 } from "~/migrations/5.37.0/002/ddb"; import { AcoFolders_5_37_0_003 } from "~/migrations/5.37.0/003/ddb"; import { AcoRecords_5_37_0_004 } from "~/migrations/5.37.0/004/ddb"; import { FileManager_5_37_0_005 } from "~/migrations/5.37.0/005/ddb"; +// 5.38.0 import { MultiStepForms_5_38_0_001 } from "~/migrations/5.38.0/001/ddb"; import { MultiStepForms_5_38_0_002 } from "~/migrations/5.38.0/002/ddb"; import { PageBlocks_5_38_0_003 } from "~/migrations/5.38.0/003/ddb"; +// 5.39.0 +import { FileManager_5_39_0_005 } from "~/migrations/5.39.0/005/ddb"; export const migrations = () => { return [ @@ -33,6 +36,7 @@ export const migrations = () => { FileManager_5_37_0_005, MultiStepForms_5_38_0_001, MultiStepForms_5_38_0_002, - PageBlocks_5_38_0_003 + PageBlocks_5_38_0_003, + FileManager_5_39_0_005 ]; }; diff --git a/packages/migrations/src/migrations/5.39.0/005/ddb-es/FileManager_5_39_0_005.ts b/packages/migrations/src/migrations/5.39.0/005/ddb-es/FileManager_5_39_0_005.ts new file mode 100644 index 00000000000..7efa31753a6 --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/ddb-es/FileManager_5_39_0_005.ts @@ -0,0 +1,212 @@ +import { Client } from "@elastic/elasticsearch"; +import { inject, makeInjectable } from "@webiny/ioc"; +import { executeWithRetry } from "@webiny/utils"; +import { PrimitiveValue } from "@webiny/api-elasticsearch/types"; +import { + DataMigration, + DataMigrationContext, + ElasticsearchClientSymbol, + PrimaryDynamoTableSymbol +} from "@webiny/data-migration"; +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { + esQueryAllWithCallback, + forEachTenantLocale, + esFindOne, + esGetIndexExist, + esGetIndexName +} from "~/utils"; +import { FileEntry } from "../utils/createFileEntity"; +import { FileMetadata } from "../utils/FileMetadata"; + +const isGroupMigrationCompleted = ( + status: PrimitiveValue[] | boolean | undefined +): status is boolean => { + return typeof status === "boolean"; +}; + +export class FileManager_5_39_0_005 implements DataMigration { + private readonly elasticsearchClient: Client; + private readonly bucket: string; + private readonly s3: S3; + private readonly table: Table; + + constructor(table: Table, elasticsearchClient: Client) { + this.table = table; + this.elasticsearchClient = elasticsearchClient; + this.s3 = new S3({ region: process.env.AWS_REGION }); + this.bucket = String(process.env.S3_BUCKET); + } + + getId() { + return "5.39.0-005"; + } + + getDescription() { + return "Generate a metadata file for every File Manager file."; + } + + private getIndexParams(tenantId: string, localeCode: string) { + return { + tenant: tenantId, + locale: localeCode, + type: "fmFile", + isHeadlessCmsModel: true + }; + } + + async shouldExecute({ logger }: DataMigrationContext): Promise { + let shouldExecute = false; + + await forEachTenantLocale({ + table: this.table, + logger, + callback: async ({ tenantId, localeCode }) => { + const indexExists = await esGetIndexExist({ + elasticsearchClient: this.elasticsearchClient, + ...this.getIndexParams(tenantId, localeCode) + }); + + if (!indexExists) { + logger.info( + `No elasticsearch index found for File Manager in tenant "${tenantId}" and locale "${localeCode}".` + ); + return true; + } + + // Fetch the latest file record from ES + const fmIndexName = esGetIndexName(this.getIndexParams(tenantId, localeCode)); + + const latestFile = await esFindOne({ + elasticsearchClient: this.elasticsearchClient, + index: fmIndexName, + body: { + query: { + bool: { + filter: [ + { term: { "tenant.keyword": tenantId } }, + { term: { "locale.keyword": localeCode } } + ] + } + }, + sort: [ + { + "id.keyword": { order: "desc", unmapped_type: "keyword" } + } + ] + } + }); + + if (!latestFile) { + logger.info( + `No files found in tenant "${tenantId}" and locale "${localeCode}".` + ); + return true; + } + + const fileMetadata = new FileMetadata(this.s3, this.bucket, latestFile); + const hasMetadata = await fileMetadata.exists(); + + if (!hasMetadata) { + shouldExecute = true; + return false; + } + + // Continue to the next tenant/locale. + return true; + } + }); + + return shouldExecute; + } + + async execute({ logger, ...context }: DataMigrationContext): Promise { + const migrationStatus = context.checkpoint || {}; + + await forEachTenantLocale({ + table: this.table, + logger, + callback: async ({ tenantId, localeCode }) => { + const groupId = `${tenantId}:${localeCode}`; + const status = migrationStatus[groupId]; + + if (isGroupMigrationCompleted(status)) { + return true; + } + + const esIndexName = esGetIndexName(this.getIndexParams(tenantId, localeCode)); + + let batch = 0; + await esQueryAllWithCallback({ + elasticsearchClient: this.elasticsearchClient, + index: esIndexName, + body: { + query: { + bool: { + filter: [ + { term: { "tenant.keyword": tenantId } }, + { term: { "locale.keyword": localeCode } } + ] + } + }, + size: 10000, + sort: [ + { + "id.keyword": { order: "asc", unmapped_type: "keyword" } + } + ], + search_after: status + }, + callback: async (files, cursor) => { + batch++; + + logger.info( + `Processing batch #${batch} in group ${groupId} (${files.length} files).` + ); + + const writers = files.map(file => { + const fileMetadata = new FileMetadata(this.s3, this.bucket, file); + const writeMetadata = () => fileMetadata.create(); + + return executeWithRetry(writeMetadata, { + onFailedAttempt: error => { + logger.error( + `"batchWriteAll" attempt #${error.attemptNumber} failed.` + ); + logger.error(error.message); + } + }); + }); + + await Promise.all(writers); + + // Update checkpoint after every batch + migrationStatus[groupId] = cursor; + + // Check if we should store checkpoint and exit. + if (context.runningOutOfTime()) { + await context.createCheckpointAndExit(migrationStatus); + } else { + await context.createCheckpoint(migrationStatus); + } + } + }); + + // Mark group as completed. + migrationStatus[groupId] = true; + + // Store checkpoint. + await context.createCheckpoint(migrationStatus); + + // Continue processing. + return true; + } + }); + } +} + +makeInjectable(FileManager_5_39_0_005, [ + inject(PrimaryDynamoTableSymbol), + inject(ElasticsearchClientSymbol) +]); diff --git a/packages/migrations/src/migrations/5.39.0/005/ddb-es/index.ts b/packages/migrations/src/migrations/5.39.0/005/ddb-es/index.ts new file mode 100644 index 00000000000..4aa9a528b3d --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/ddb-es/index.ts @@ -0,0 +1 @@ +export * from "./FileManager_5_39_0_005"; diff --git a/packages/migrations/src/migrations/5.39.0/005/ddb/FileManager_5_39_0_005.ts b/packages/migrations/src/migrations/5.39.0/005/ddb/FileManager_5_39_0_005.ts new file mode 100644 index 00000000000..88d6ef35a43 --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/ddb/FileManager_5_39_0_005.ts @@ -0,0 +1,157 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { inject, makeInjectable } from "@webiny/ioc"; +import { executeWithRetry } from "@webiny/utils"; +import { PrimitiveValue } from "@webiny/api-elasticsearch/types"; +import { + DataMigration, + DataMigrationContext, + PrimaryDynamoTableSymbol +} from "@webiny/data-migration"; +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { QueryAllParams } from "@webiny/db-dynamodb"; +import { ddbQueryAllWithCallback, forEachTenantLocale, queryOne } from "~/utils"; +import { createFileEntity, FileEntry } from "../utils/createFileEntity"; +import { FileMetadata } from "../utils/FileMetadata"; + +const isGroupMigrationCompleted = ( + status: PrimitiveValue[] | boolean | undefined +): status is boolean => { + return typeof status === "boolean"; +}; + +export class FileManager_5_39_0_005 implements DataMigration { + private readonly fileEntity: ReturnType; + private readonly table: Table; + private readonly bucket: string; + private readonly s3: S3; + + constructor(table: Table) { + this.table = table; + this.fileEntity = createFileEntity(table); + this.s3 = new S3({ region: process.env.AWS_REGION }); + this.bucket = String(process.env.S3_BUCKET); + } + + getId() { + return "5.39.0-005"; + } + + getDescription() { + return "Generate a metadata file for every File Manager file."; + } + + async shouldExecute({ logger }: DataMigrationContext): Promise { + let shouldExecute = false; + + await forEachTenantLocale({ + table: this.table, + logger, + callback: async ({ tenantId, localeCode }) => { + const latestFile = await queryOne( + this.getFileQuery(tenantId, localeCode) + ); + + if (!latestFile) { + return false; + } + + const fileMetadata = new FileMetadata(this.s3, this.bucket, latestFile); + + const hasMetadata = await fileMetadata.exists(); + + if (!hasMetadata) { + shouldExecute = true; + return false; + } + + // Continue to the next tenant/locale. + return true; + } + }); + + return shouldExecute; + } + + async execute({ logger, ...context }: DataMigrationContext): Promise { + const migrationStatus = context.checkpoint || {}; + + let batch = 0; + + await forEachTenantLocale({ + table: this.table, + logger, + callback: async ({ tenantId, localeCode }) => { + const groupId = `${tenantId}:${localeCode}`; + const status = migrationStatus[groupId]; + + if (isGroupMigrationCompleted(status)) { + return true; + } + + await ddbQueryAllWithCallback( + this.getFileQuery(tenantId, localeCode, { gt: status || " ", limit: 1000 }), + async files => { + batch++; + + logger.info( + `Processing batch #${batch} in group ${groupId} (${files.length} files).` + ); + + const writers = files.map(file => { + const fileMetadata = new FileMetadata(this.s3, this.bucket, file); + const writeMetadata = () => fileMetadata.create(); + + return executeWithRetry(writeMetadata, { + onFailedAttempt: error => { + logger.error( + `"batchWriteAll" attempt #${error.attemptNumber} failed.` + ); + logger.error(error.message); + } + }); + }); + + await Promise.all(writers); + + // Update checkpoint after every batch + migrationStatus[groupId] = files[files.length - 1]?.id; + + // Check if we should store checkpoint and exit. + if (context.runningOutOfTime()) { + await context.createCheckpointAndExit(migrationStatus); + } else { + await context.createCheckpoint(migrationStatus); + } + } + ); + + // Mark group as completed. + migrationStatus[groupId] = true; + + // Store checkpoint. + await context.createCheckpoint(migrationStatus); + + // Continue processing. + return true; + } + }); + } + + private getFileQuery( + tenantId: string, + localeCode: string, + options: QueryAllParams["options"] = {} + ) { + return { + entity: this.fileEntity, + partitionKey: `T#${tenantId}#L#${localeCode}#CMS#CME#M#fmFile#L`, + options: { + index: "GSI1", + gt: " ", + ...options + } + }; + } +} + +makeInjectable(FileManager_5_39_0_005, [inject(PrimaryDynamoTableSymbol)]); diff --git a/packages/migrations/src/migrations/5.39.0/005/ddb/index.ts b/packages/migrations/src/migrations/5.39.0/005/ddb/index.ts new file mode 100644 index 00000000000..4aa9a528b3d --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/ddb/index.ts @@ -0,0 +1 @@ +export * from "./FileManager_5_39_0_005"; diff --git a/packages/migrations/src/migrations/5.39.0/005/utils/FileMetadata.ts b/packages/migrations/src/migrations/5.39.0/005/utils/FileMetadata.ts new file mode 100644 index 00000000000..7ac7b077fbc --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/utils/FileMetadata.ts @@ -0,0 +1,60 @@ +import { S3 } from "@webiny/aws-sdk/client-s3"; +import { FileEntry } from "./createFileEntity"; + +export class FileMetadata { + private s3: S3; + private fileEntry: FileEntry; + private readonly bucket: string; + private readonly metadataKey: string; + private attempt = 0; + + constructor(s3: S3, bucket: string, fileEntry: FileEntry) { + this.bucket = bucket; + this.s3 = s3; + this.fileEntry = fileEntry; + + const fileKey = fileEntry.values["text@key"]; + this.metadataKey = `${fileKey}.metadata`; + } + + async create() { + const metadata = { + id: this.fileEntry.entryId, + tenant: this.fileEntry.tenant, + locale: this.fileEntry.locale, + size: this.fileEntry.values["number@size"], + contentType: this.fileEntry.values["text@type"] + }; + + try { + this.attempt++; + console.log(`Attempt #${this.attempt}: create metadata file at ${this.metadataKey}`); + await this.s3.putObject({ + Bucket: this.bucket, + Key: this.metadataKey, + Body: JSON.stringify(metadata, null, 2) + }); + console.log(`Attempt #${this.attempt} succeeded! Created ${this.metadataKey}`); + } catch (error) { + console.log( + `ERROR #${this.attempt} for ${this.metadataKey}`, + JSON.stringify(error, null, 2) + ); + } + } + + async exists() { + try { + await this.s3.headObject({ Bucket: this.bucket, Key: this.metadataKey }); + return true; + } catch (error) { + if (error.name === "NotFound") { + return false; + } + + console.log("ERROR: couldn't check for metadata", JSON.stringify(error, null, 2)); + + return false; + } + } +} diff --git a/packages/migrations/src/migrations/5.39.0/005/utils/createFileEntity.ts b/packages/migrations/src/migrations/5.39.0/005/utils/createFileEntity.ts new file mode 100644 index 00000000000..1be94a29c30 --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/utils/createFileEntity.ts @@ -0,0 +1,56 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { createLegacyEntity } from "~/utils"; + +const ddbAttributes: Parameters[2] = { + PK: { + type: "string", + partitionKey: true + }, + SK: { + type: "string", + sortKey: true + }, + GSI1_PK: { + type: "string" + }, + GSI1_SK: { + type: "string" + }, + TYPE: { + type: "string" + }, + __type: { + type: "string" + }, + tenant: { + type: "string" + }, + locale: { + type: "string" + }, + entryId: { + type: "string" + }, + id: { + type: "string" + }, + values: { + type: "map" + } +}; + +export interface FileEntry { + id: string; + entryId: string; + tenant: string; + locale: string; + values: { + "text@key": string; + "number@size": number; + "text@type": string; + }; +} + +export const createFileEntity = (table: Table) => { + return createLegacyEntity(table, "CmsEntries", ddbAttributes); +}; diff --git a/packages/migrations/src/migrations/5.39.0/005/utils/createLocaleEntity.ts b/packages/migrations/src/migrations/5.39.0/005/utils/createLocaleEntity.ts new file mode 100644 index 00000000000..80fa7c4b29a --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/utils/createLocaleEntity.ts @@ -0,0 +1,25 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { createLegacyEntity } from "~/utils"; + +export const createLocaleEntity = (table: Table) => { + return createLegacyEntity(table, "I18NLocale", { + createdOn: { + type: "string" + }, + createdBy: { + type: "map" + }, + code: { + type: "string" + }, + default: { + type: "boolean" + }, + webinyVersion: { + type: "string" + }, + tenant: { + type: "string" + } + }); +}; diff --git a/packages/migrations/src/migrations/5.39.0/005/utils/createTenantEntity.ts b/packages/migrations/src/migrations/5.39.0/005/utils/createTenantEntity.ts new file mode 100644 index 00000000000..5b7f8a7e7ef --- /dev/null +++ b/packages/migrations/src/migrations/5.39.0/005/utils/createTenantEntity.ts @@ -0,0 +1,6 @@ +import { Table } from "@webiny/db-dynamodb/toolbox"; +import { createStandardEntity } from "~/utils"; + +export const createTenantEntity = (table: Table) => { + return createStandardEntity(table, "TenancyTenant"); +}; diff --git a/packages/project-utils/bundling/function/buildFunction.js b/packages/project-utils/bundling/function/buildFunction.js index 40bcf39f73f..fc7684ebdd7 100644 --- a/packages/project-utils/bundling/function/buildFunction.js +++ b/packages/project-utils/bundling/function/buildFunction.js @@ -7,7 +7,7 @@ module.exports = async options => { const duration = getDuration(); const path = require("path"); - const { overrides, logs, cwd } = options; + const { overrides, logs, cwd, debug } = options; let projectApplication; try { @@ -19,7 +19,7 @@ module.exports = async options => { logs && console.log(`Compiling ${chalk.green(path.basename(cwd))}...`); let webpackConfig = require("./webpack.config")({ - production: true, + production: !debug, projectApplication, ...options }); diff --git a/packages/project-utils/bundling/function/webpack.config.js b/packages/project-utils/bundling/function/webpack.config.js index aaa29c05694..0206a76660c 100644 --- a/packages/project-utils/bundling/function/webpack.config.js +++ b/packages/project-utils/bundling/function/webpack.config.js @@ -2,7 +2,6 @@ const path = require("path"); const fs = require("fs"); const webpack = require("webpack"); const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); - const { version } = require("@webiny/project-utils/package.json"); const { getOutput, getEntry } = require("./utils"); @@ -37,10 +36,11 @@ module.exports = options => { output: { libraryTarget: "commonjs", path: output.path, - filename: output.filename + filename: output.filename, + chunkFilename: `[name].[contenthash:8].chunk.js` }, devtool: sourceMaps ? "source-map" : false, - externals: [/^aws-sdk/], + externals: [/^aws-sdk/, /^sharp$/], mode: production ? "production" : "development", optimization: { minimize: production @@ -55,6 +55,13 @@ module.exports = options => { "process.env.WEBINY_VERSION": JSON.stringify(process.env.WEBINY_VERSION || version), ...definitions }), + /** + * This is necessary to enable JSDOM usage in Lambda. + */ + new webpack.IgnorePlugin({ + resourceRegExp: /canvas/, + contextRegExp: /jsdom$/ + }), tsChecksEnabled && new ForkTsCheckerWebpackPlugin({ typescript: { @@ -69,30 +76,42 @@ module.exports = options => { module: { exprContextCritical: false, rules: [ - sourceMaps && { - test: /\.js$/, - enforce: "pre", - use: [require.resolve("source-map-loader")] - }, { - test: /\.mjs$/, - include: /node_modules/, - type: "javascript/auto", - resolve: { - fullySpecified: false - } + oneOf: [ + sourceMaps && { + test: /\.js$/, + enforce: "pre", + use: [require.resolve("source-map-loader")] + }, + { + test: /\.mjs$/, + include: /node_modules/, + type: "javascript/auto", + resolve: { + fullySpecified: false + } + }, + { + test: /\.(js|ts)$/, + loader: require.resolve("babel-loader"), + exclude: /node_modules/, + options: babelOptions + } + ].filter(Boolean) }, + /** + * Some NPM libraries import CSS automatically, and that breaks the build. + * To eliminate the problem, we use the `null-loader` to ignore CSS. + */ { - test: /\.(js|ts)$/, - loader: require.resolve("babel-loader"), - exclude: /node_modules/, - options: babelOptions + test: /\.css$/, + loader: require.resolve("null-loader") } - ].filter(Boolean) + ] }, resolve: { modules: [path.resolve(path.join(cwd, "node_modules")), "node_modules"], - extensions: [".ts", ".mjs", ".js", ".json"] + extensions: [".ts", ".mjs", ".js", ".json", ".css"] } }; }; diff --git a/packages/project-utils/configs/tsconfig.build.json b/packages/project-utils/configs/tsconfig.build.json new file mode 100644 index 00000000000..34272bcbe17 --- /dev/null +++ b/packages/project-utils/configs/tsconfig.build.json @@ -0,0 +1,51 @@ +{ + "compilerOptions": { + "target": "es6", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "module": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "esModuleInterop": true, + "declaration": true, + "composite": true, + "noEmit": false, + "jsx": "preserve", + "emitDeclarationOnly": true, + "baseUrl": ".", + "paths": {}, + "noUnusedParameters": false, + "noUnusedLocals": false, + "noImplicitUseStrict": false, + "noImplicitThis": true, + "noImplicitReturns": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": false, + "noStrictGenericChecks": false, + "noFallthroughCasesInSwitch": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitOverride": true, + "strict": true, + "noPropertyAccessFromIndexSignature": true, + "suppressImplicitAnyIndexErrors": false, + "suppressExcessPropertyErrors": false, + "keyofStringsOnly": false, + "experimentalDecorators": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + /** + * If you set to true there would need to be A LOT of error handling changes. + * https://www.typescriptlang.org/tsconfig#useUnknownInCatchVariables + */ + "useUnknownInCatchVariables": false, + /** + * Setting to true will start producing TS errors that are inside libraries we use. + * https://www.typescriptlang.org/tsconfig#exactOptionalPropertyTypes + */ + "exactOptionalPropertyTypes": false + } +} diff --git a/packages/project-utils/package.json b/packages/project-utils/package.json index 21afc2c9602..366656dc0c3 100644 --- a/packages/project-utils/package.json +++ b/packages/project-utils/package.json @@ -48,6 +48,7 @@ "html-webpack-plugin": "5.5.0", "lodash": "^4.6.2", "mini-css-extract-plugin": "2.4.5", + "null-loader": "^4.0.1", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", "postcss-flexbugs-fixes": "5.0.2", diff --git a/packages/project-utils/packages/createBabelConfigForNode.js b/packages/project-utils/packages/createBabelConfigForNode.js index 62a3f904175..43f2d4e7988 100644 --- a/packages/project-utils/packages/createBabelConfigForNode.js +++ b/packages/project-utils/packages/createBabelConfigForNode.js @@ -7,14 +7,21 @@ module.exports = ({ path, esm }) => { targets: { node: "18" }, - modules: esm ? false : "auto" + modules: esm ? false : "auto", + exclude: [ + "transform-typeof-symbol", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-proposal-nullish-coalescing-operator", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-async-to-generator", + "@babel/plugin-transform-regenerator", + "@babel/plugin-proposal-dynamic-import" + ] } ], "@babel/preset-typescript" ], plugins: [ - ["@babel/plugin-proposal-class-properties"], - ["@babel/plugin-proposal-object-rest-spread", { useBuiltIns: true }], [ "@babel/plugin-transform-runtime", { @@ -22,7 +29,6 @@ module.exports = ({ path, esm }) => { version: require("@babel/runtime/package.json").version } ], - ["babel-plugin-dynamic-import-node"], [ "babel-plugin-module-resolver", { diff --git a/packages/project-utils/packages/createBabelConfigForReact.js b/packages/project-utils/packages/createBabelConfigForReact.js index e529e3dc2ec..6cc1c502df2 100644 --- a/packages/project-utils/packages/createBabelConfigForReact.js +++ b/packages/project-utils/packages/createBabelConfigForReact.js @@ -4,7 +4,7 @@ module.exports = ({ path, esm }) => ({ "@babel/preset-env", { targets: { - browsers: ["last 2 versions", "safari >= 7"] + browsers: ["last 2 versions"] }, // Allow importing core-js in entrypoint and use browserlist to select polyfills useBuiltIns: "entry", @@ -14,7 +14,13 @@ module.exports = ({ path, esm }) => ({ // Do not transform modules to CJS modules: esm ? false : "auto", // Exclude transforms that make all code slower - exclude: ["transform-typeof-symbol"] + exclude: [ + "transform-typeof-symbol", + "@babel/plugin-proposal-optional-chaining", + "@babel/plugin-proposal-nullish-coalescing-operator", + "@babel/plugin-transform-async-to-generator", + "@babel/plugin-transform-regenerator" + ] } ], ["@babel/preset-react", { useBuiltIns: true }], @@ -22,7 +28,6 @@ module.exports = ({ path, esm }) => ({ ], plugins: [ "babel-plugin-macros", - "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-throw-expressions", [ "@babel/plugin-transform-runtime", @@ -33,8 +38,6 @@ module.exports = ({ path, esm }) => ({ useESModules: false } ], - "@babel/plugin-proposal-optional-chaining", - "@babel/plugin-proposal-nullish-coalescing-operator", ["babel-plugin-emotion", { autoLabel: true }], [ "@babel/plugin-proposal-object-rest-spread", diff --git a/packages/project-utils/testing/dynamodb/index.d.ts b/packages/project-utils/testing/dynamodb/index.d.ts index c2d15b5b2da..3bf9ab3942b 100644 --- a/packages/project-utils/testing/dynamodb/index.d.ts +++ b/packages/project-utils/testing/dynamodb/index.d.ts @@ -1,4 +1,8 @@ -import { DynamoDBClient, DynamoDBClientConfig } from "@webiny/aws-sdk/client-dynamodb"; +import { + DynamoDBClient, + DynamoDBClientConfig, + DynamoDBDocument +} from "@webiny/aws-sdk/client-dynamodb"; -export function getDocumentClient(params?: DynamoDBClientConfig, force?: boolean): DynamoDBClient; +export function getDocumentClient(params?: DynamoDBClientConfig, force?: boolean): DynamoDBDocument; export function simulateStream(documentClient: DynamoDBClient, handler: any): void; diff --git a/packages/project-utils/testing/elasticsearch/createClient.d.ts b/packages/project-utils/testing/elasticsearch/createClient.d.ts index 1324196f2fe..6f787e1bf72 100644 --- a/packages/project-utils/testing/elasticsearch/createClient.d.ts +++ b/packages/project-utils/testing/elasticsearch/createClient.d.ts @@ -14,3 +14,5 @@ export { ElasticsearchClientOptions, ElasticsearchClient }; export declare function createElasticsearchClient( options?: Partial ): ElasticsearchClient; + +export type { ElasticsearchClient as Client }; diff --git a/packages/pulumi-aws/src/apps/api/ApiBackgroundTask.ts b/packages/pulumi-aws/src/apps/api/ApiBackgroundTask.ts new file mode 100644 index 00000000000..69b9e815f04 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/ApiBackgroundTask.ts @@ -0,0 +1,142 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as aws from "@pulumi/aws"; +import { createAppModule, PulumiApp, PulumiAppModule } from "@webiny/pulumi"; +import { ApiGraphql, CoreOutput } from "~/apps"; +import { createBackgroundTaskDefinition } from "./backgroundTask/definition"; +import { createBackgroundTaskStepFunctionPolicy } from "~/apps/api/backgroundTask/policy"; +import { createBackgroundTaskStepFunctionRole } from "./backgroundTask/role"; +import { getLayerArn } from "@webiny/aws-layers"; + +export type ApiBackgroundTask = PulumiAppModule; + +export const ApiBackgroundTaskLambdaName = "background-task"; + +export const ApiBackgroundTask = createAppModule({ + name: "ApiBackgroundTask", + config(app: PulumiApp) { + const core = app.getModule(CoreOutput); + const graphql = app.getModule(ApiGraphql); + const baseConfig = graphql.functions.graphql.config.clone(); + + const backgroundTask = app.addResource(aws.lambda.Function, { + name: ApiBackgroundTaskLambdaName, + config: { + ...baseConfig, + layers: graphql.functions.graphql.output.layers.apply(arns => { + return Array.from(new Set([...(arns || []), getLayerArn("sharp")])); + }), + timeout: 900, + memorySize: 512, + description: "Performs background tasks." + } + }); + + const stepFunctionPolicy = createBackgroundTaskStepFunctionPolicy(app, { + name: "background-task-sfn-policy", + lambdaFunctionArn: backgroundTask.output.arn + }); + + const stepFunctionRole = createBackgroundTaskStepFunctionRole(app, { + name: "background-task-sfn-role", + policy: stepFunctionPolicy.output + }); + + const stepFunction = app.addResource(aws.sfn.StateMachine, { + name: "background-task-sfn", + config: { + // TODO logging to cloudwatch + /* + loggingConfiguration: { + level: "ALL", + includeExecutionData: true, + // insert real ARN + logDestination: ARN + */ + roleArn: stepFunctionRole.output.arn, + definition: pulumi.jsonStringify( + createBackgroundTaskDefinition({ + lambdaName: ApiBackgroundTaskLambdaName, + lambdaArn: backgroundTask.output.arn + }) + ) + } + }); + + const eventRole = app.addResource(aws.iam.Role, { + name: "background-task-event-role", + config: { + assumeRolePolicy: { + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "events.amazonaws.com" + } + } + ] + } + } + }); + + const eventPolicy = app.addResource(aws.iam.Policy, { + name: "background-task-event-policy", + config: { + policy: { + Version: "2012-10-17", + Statement: [ + { + Action: "states:StartExecution", + Effect: "Allow", + Resource: stepFunction.output.arn + } + ] + } + } + }); + + const eventRolePolicyAttachment = app.addResource(aws.iam.RolePolicyAttachment, { + name: "background-task-event-role-policy-attachment", + config: { + role: eventRole.output.name, + policyArn: eventPolicy.output.arn + } + }); + + const eventRule = app.addResource(aws.cloudwatch.EventRule, { + name: "background-task-event-rule", + config: { + eventBusName: core.eventBusName, + roleArn: eventRole.output.arn, + eventPattern: JSON.stringify({ + "detail-type": ["WebinyBackgroundTask"] + }) + } + }); + + const eventTarget = app.addResource(aws.cloudwatch.EventTarget, { + name: "background-task-event-target", + config: { + // This is going to get called. + arn: stepFunction.output.arn, + // This is the rule which determines if this target gets called. + rule: eventRule.output.name, + // This is the role which gets assumed when calling the target. + roleArn: eventRole.output.arn, + // This is the event bus name. + eventBusName: core.eventBusName + } + }); + + return { + backgroundTask, + stepFunction, + eventRole, + eventPolicy, + eventRolePolicyAttachment, + eventRule, + eventTarget + }; + } +}); diff --git a/packages/pulumi-aws/src/apps/api/ApiCloudfront.ts b/packages/pulumi-aws/src/apps/api/ApiCloudfront.ts index 93ad67346cf..82e5020b0a5 100644 --- a/packages/pulumi-aws/src/apps/api/ApiCloudfront.ts +++ b/packages/pulumi-aws/src/apps/api/ApiCloudfront.ts @@ -10,6 +10,13 @@ export const ApiCloudfront = createAppModule({ config(app: PulumiApp) { const gateway = app.getModule(ApiGateway); + const cookies = { + forward: "whitelist", + whitelistedNames: ["wby-id-token"] + }; + + const forwardHeaders = ["Origin", "Accept", "Accept-Language"]; + return app.addResource(aws.cloudfront.Distribution, { name: "api-cloudfront", config: { @@ -21,10 +28,8 @@ export const ApiCloudfront = createAppModule({ allowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], cachedMethods: ["GET", "HEAD", "OPTIONS"], forwardedValues: { - cookies: { - forward: "none" - }, - headers: ["Accept", "Accept-Language"], + cookies, + headers: forwardHeaders, queryString: true }, // MinTTL <= DefaultTTL <= MaxTTL @@ -59,21 +64,13 @@ export const ApiCloudfront = createAppModule({ targetOriginId: gateway.api.output.name }, { - allowedMethods: [ - "GET", - "HEAD", - "OPTIONS", - "PUT", - "POST", - "PATCH", - "DELETE" - ], - cachedMethods: ["GET", "HEAD", "OPTIONS"], + allowedMethods: ["HEAD", "GET", "OPTIONS"], + cachedMethods: ["HEAD", "GET", "OPTIONS"], forwardedValues: { cookies: { forward: "none" }, - headers: ["Accept", "Accept-Language"], + headers: forwardHeaders, queryString: true }, // MinTTL <= DefaultTTL <= MaxTTL @@ -83,6 +80,22 @@ export const ApiCloudfront = createAppModule({ pathPattern: "/files/*", viewerProtocolPolicy: "allow-all", targetOriginId: gateway.api.output.name + }, + { + allowedMethods: ["HEAD", "GET", "OPTIONS"], + cachedMethods: ["HEAD", "GET", "OPTIONS"], + forwardedValues: { + cookies: cookies, + headers: forwardHeaders, + queryString: true + }, + // MinTTL <= DefaultTTL <= MaxTTL + minTtl: 0, + defaultTtl: 0, + maxTtl: 2592000, + pathPattern: "/private/*", + viewerProtocolPolicy: "allow-all", + targetOriginId: gateway.api.output.name } ], origins: [ diff --git a/packages/pulumi-aws/src/apps/api/ApiFileManager.ts b/packages/pulumi-aws/src/apps/api/ApiFileManager.ts index bb4aaaaade2..cd7d10092f3 100644 --- a/packages/pulumi-aws/src/apps/api/ApiFileManager.ts +++ b/packages/pulumi-aws/src/apps/api/ApiFileManager.ts @@ -1,13 +1,11 @@ import path from "path"; import * as pulumi from "@pulumi/pulumi"; import * as aws from "@pulumi/aws"; - -// @ts-expect-error import { getLayerArn } from "@webiny/aws-layers"; import { createAppModule, PulumiApp, PulumiAppModule } from "@webiny/pulumi"; import { createLambdaRole, getCommonLambdaEnvVariables } from "../lambdaUtils"; -import { CoreOutput, VpcConfig } from "../common"; +import { ApiGraphql, CoreOutput, VpcConfig } from "~/apps"; import { getAwsAccountId } from "~/apps/awsUtils"; import { LAMBDA_RUNTIME } from "~/constants"; @@ -21,6 +19,7 @@ export const ApiFileManager = createAppModule({ name: "ApiFileManager", config(app: PulumiApp, config: ApiFileManagerConfig) { const core = app.getModule(CoreOutput); + const graphql = app.getModule(ApiGraphql); const accountId = getAwsAccountId(app); const policy = createFileManagerLambdaPolicy(app); @@ -29,31 +28,6 @@ export const ApiFileManager = createAppModule({ policy: policy.output }); - const transform = app.addResource(aws.lambda.Function, { - name: "fm-image-transformer", - config: { - handler: "handler.handler", - timeout: 30, - runtime: LAMBDA_RUNTIME, - memorySize: 1600, - role: role.output.arn, - description: "Performs image optimization, resizing, etc.", - code: new pulumi.asset.AssetArchive({ - ".": new pulumi.asset.FileArchive( - path.join(app.paths.workspace, "fileManager/transform/build") - ) - }), - layers: [getLayerArn("sharp")], - environment: { - variables: getCommonLambdaEnvVariables().apply(value => ({ - ...value, - S3_BUCKET: core.fileManagerBucketId - })) - }, - vpcConfig: app.getModule(VpcConfig).functionVpcConfig - } - }); - const manage = app.addResource(aws.lambda.Function, { name: "fm-manage", config: { @@ -78,29 +52,24 @@ export const ApiFileManager = createAppModule({ } }); + const baseConfig = graphql.functions.graphql.config.clone(); + const download = app.addResource(aws.lambda.Function, { name: "fm-download", config: { - role: role.output.arn, - runtime: LAMBDA_RUNTIME, - handler: "handler.handler", - timeout: 30, - memorySize: 512, + ...baseConfig, + memorySize: 1600, description: "Serves previously uploaded files.", - code: new pulumi.asset.AssetArchive({ - ".": new pulumi.asset.FileArchive( - path.join(app.paths.workspace, "fileManager/download/build") - ) - }), + layers: [getLayerArn("sharp")], environment: { - variables: getCommonLambdaEnvVariables().apply(value => ({ - ...value, - S3_BUCKET: core.fileManagerBucketId, - IMAGE_TRANSFORMER_FUNCTION: transform.output.arn, - ...config.env - })) - }, - vpcConfig: app.getModule(VpcConfig).functionVpcConfig + variables: graphql.functions.graphql.output.environment.apply(env => { + return { + WEBINY_FUNCTION_TYPE: "asset-delivery", + ...env?.variables, + ...config.env + }; + }) + } } }); @@ -135,7 +104,6 @@ export const ApiFileManager = createAppModule({ }); const functions = { - transform, manage, download }; diff --git a/packages/pulumi-aws/src/apps/api/ApiGraphql.ts b/packages/pulumi-aws/src/apps/api/ApiGraphql.ts index 4201f0a4c5a..bad33b57b3a 100644 --- a/packages/pulumi-aws/src/apps/api/ApiGraphql.ts +++ b/packages/pulumi-aws/src/apps/api/ApiGraphql.ts @@ -198,13 +198,17 @@ function createGraphqlLambdaPolicy(app: PulumiApp) { Sid: "PermissionForS3", Effect: "Allow", Action: [ + "s3:ListBucket", "s3:GetObjectAcl", "s3:DeleteObject", "s3:PutObjectAcl", "s3:PutObject", "s3:GetObject" ], - Resource: `arn:aws:s3:::${core.fileManagerBucketId}/*` + Resource: [ + pulumi.interpolate`arn:aws:s3:::${core.fileManagerBucketId}`, + pulumi.interpolate`arn:aws:s3:::${core.fileManagerBucketId}/*` + ] }, { Sid: "PermissionForLambda", @@ -224,6 +228,12 @@ function createGraphqlLambdaPolicy(app: PulumiApp) { Action: "events:PutEvents", Resource: core.eventBusArn }, + { + Sid: "PermissionForCloudfront", + Effect: "Allow", + Action: "cloudfront:CreateInvalidation", + Resource: pulumi.interpolate`arn:aws:cloudfront::${awsAccountId}:distribution/*` + }, // Attach permissions for elastic search domain as well (if ES is enabled). ...(core.elasticsearchDomainArn ? [ diff --git a/packages/pulumi-aws/src/apps/api/backgroundTask/definition.ts b/packages/pulumi-aws/src/apps/api/backgroundTask/definition.ts new file mode 100644 index 00000000000..940abafef07 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/backgroundTask/definition.ts @@ -0,0 +1,160 @@ +import * as pulumi from "@pulumi/pulumi"; +import { StepFunctionDefinition, StepFunctionDefinitionStatesType } from "./types"; + +export interface BackgroundTaskParams { + lambdaName: string; + lambdaArn: pulumi.Input; +} + +export const createBackgroundTaskDefinition = ( + params: BackgroundTaskParams +): StepFunctionDefinition => { + const { lambdaName, lambdaArn } = params; + return { + Comment: "Background tasks", + StartAt: "TransformEvent", + States: { + /** + * Transform the EventBridge event to a format that will be used in the Lambda. + */ + TransformEvent: { + Type: StepFunctionDefinitionStatesType.Pass, + Next: "Run", + Parameters: { + "webinyTaskId.$": "$.detail.webinyTaskId", + "webinyTaskDefinitionId.$": "$.detail.webinyTaskDefinitionId", + "tenant.$": "$.detail.tenant", + "locale.$": "$.detail.locale" + } + }, + /** + * Run the task and wait for the response from lambda. + * On some fatal error go to Error step. + * In other cases, check the status of the task. + */ + Run: { + Type: StepFunctionDefinitionStatesType.Task, + Resource: lambdaArn, + Next: "CheckStatus", + ResultPath: "$", + InputPath: "$", + /** + * Parameters will be received as an event in the Lambda. + * Task Handler determines that it can run a task based on the Payload.webinyTaskId parameter - it must be set! + */ + Parameters: { + name: lambdaName, + payload: { + "webinyTaskId.$": "$.webinyTaskId", + "webinyTaskDefinitionId.$": "$.webinyTaskDefinitionId", + "locale.$": "$.locale", + "tenant.$": "$.tenant", + endpoint: "manage", + "executionName.$": "$$.Execution.Name", + "stateMachineId.$": "$$.StateMachine.Id" + } + }, + Catch: [ + { + ErrorEquals: ["States.ALL"], + Next: "UnknownError" + } + ] + }, + /** + * On CONTINUE, go back to Run. + * On ERROR, go to Error step. + * On DONE, go to Done step. + */ + CheckStatus: { + Type: StepFunctionDefinitionStatesType.Choice, + InputPath: "$", + Choices: [ + /** + * There is a possibility that the task will return a CONTINUE status and a waitUntil value. + * This means that task will wait for the specified time and then continue. + * It can be used to handle waiting for child tasks or some resource to be created. + */ + { + And: [ + { + Variable: "$.status", + StringEquals: "continue" + }, + { + Variable: "$.wait", + IsPresent: true + }, + { + Variable: "$.wait", + IsNumeric: true + }, + { + Variable: "$.wait", + NumericGreaterThan: 0 + } + ], + Next: "Waiter" + }, + /** + * When no wait value is present, go to Run. + */ + { + Variable: "$.status", + StringEquals: "continue", + Next: "Run" + }, + { + Variable: "$.status", + StringEquals: "error", + Next: "Error" + }, + { + Variable: "$.status", + StringEquals: "done", + Next: "Done" + }, + { + Variable: "$.status", + StringEquals: "aborted", + Next: "Aborted" + } + ], + Default: "UnknownStatus" + }, + Waiter: { + Type: StepFunctionDefinitionStatesType.Wait, + SecondsPath: "$.wait", + Next: "Run" + }, + UnknownError: { + Type: StepFunctionDefinitionStatesType.Fail, + Cause: "Fatal error - unknown task error." + }, + /** + * Unknown task status on Choice step. + */ + UnknownStatus: { + Type: StepFunctionDefinitionStatesType.Fail, + Cause: "Fatal error - unknown status." + }, + /** + * Fail the task and output the error. + */ + Error: { + Type: StepFunctionDefinitionStatesType.Fail, + CausePath: "States.JsonToString($.error)", + ErrorPath: "$.error.message" + }, + /** + * Complete the task. + */ + Done: { + Type: StepFunctionDefinitionStatesType.Succeed + }, + Aborted: { + Type: StepFunctionDefinitionStatesType.Succeed + } + } + }; +}; diff --git a/packages/pulumi-aws/src/apps/api/backgroundTask/policy.ts b/packages/pulumi-aws/src/apps/api/backgroundTask/policy.ts new file mode 100644 index 00000000000..f12624d593d --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/backgroundTask/policy.ts @@ -0,0 +1,43 @@ +import { PulumiApp } from "@webiny/pulumi"; +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; + +interface Params { + name: string; + lambdaFunctionArn: pulumi.Input; +} + +export const createBackgroundTaskStepFunctionPolicy = (app: PulumiApp, params: Params) => { + const { name, lambdaFunctionArn } = params; + return app.addResource(aws.iam.Policy, { + name, + config: { + policy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["lambda:InvokeFunction"], + Resource: lambdaFunctionArn + }, + { + Effect: "Allow", + Action: [ + "logs:CreateLogDelivery", + "logs:CreateLogStream", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutLogEvents", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + "logs:DescribeLogGroups" + ], + Resource: "*" + } + ] + } + } + }); +}; diff --git a/packages/pulumi-aws/src/apps/api/backgroundTask/role.ts b/packages/pulumi-aws/src/apps/api/backgroundTask/role.ts new file mode 100644 index 00000000000..43d628bb793 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/backgroundTask/role.ts @@ -0,0 +1,38 @@ +import { PulumiApp } from "@webiny/pulumi"; +import * as aws from "@pulumi/aws"; +import * as pulumi from "@pulumi/pulumi"; + +interface Params { + name: string; + policy: pulumi.Output; +} + +export const createBackgroundTaskStepFunctionRole = (app: PulumiApp, params: Params) => { + const { name, policy } = params; + const role = app.addResource(aws.iam.Role, { + name, + config: { + assumeRolePolicy: { + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Principal: { + Service: "states.amazonaws.com" + }, + Effect: "Allow" + } + ] + } + } + }); + app.addResource(aws.iam.RolePolicyAttachment, { + name: `${name}Policy`, + config: { + role: role.output, + policyArn: policy.arn + } + }); + + return role; +}; diff --git a/packages/pulumi-aws/src/apps/api/backgroundTask/types.ts b/packages/pulumi-aws/src/apps/api/backgroundTask/types.ts new file mode 100644 index 00000000000..67697d40032 --- /dev/null +++ b/packages/pulumi-aws/src/apps/api/backgroundTask/types.ts @@ -0,0 +1,114 @@ +import * as pulumi from "@pulumi/pulumi"; + +/** + * Update types if required. Try to avoid generic objects + */ +export enum StepFunctionDefinitionStatesType { + Task = "Task", + Pass = "Pass", + Choice = "Choice", + Wait = "Wait", + Fail = "Fail", + Succeed = "Succeed" +} + +export interface StepFunctionDefinitionStatesCatch { + ErrorEquals: string[]; + Next: string; + ResultPath?: string; +} + +export interface StepFunctionDefinitionStatesChoiceBase { + Variable: string; + Next: string; + StringEquals?: string; + StringMatches?: string; + IsPresent?: boolean; +} + +export interface StepFunctionDefinitionStatesChoiceAndItem { + Variable: string; + StringEquals?: string; + StringMatches?: string; + IsPresent?: boolean; + IsTimestamp?: boolean; + IsNumeric?: boolean; + NumericGreaterThan?: number; +} +export interface StepFunctionDefinitionStatesChoiceAnd { + And: StepFunctionDefinitionStatesChoiceAndItem[]; + Next: string; +} + +export type StepFunctionDefinitionStatesChoice = + | StepFunctionDefinitionStatesChoiceBase + | StepFunctionDefinitionStatesChoiceAnd; + +export interface StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType; + Comment?: string; + InputPath?: string; + OutputPath?: string; +} + +export interface StepFunctionDefinitionStatesTypeTask extends StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType.Task; + Resource: pulumi.Input; + Next: string; + ResultPath?: string; + Parameters: Record; + Catch: StepFunctionDefinitionStatesCatch[]; + End?: boolean; +} + +export interface StepFunctionDefinitionStatesTypePass extends StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType.Pass; + Next: string; + ResultPath?: string; + Parameters: Record; + End?: boolean; +} + +export interface StepFunctionDefinitionStatesTypeChoice + extends StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType.Choice; + Choices: StepFunctionDefinitionStatesChoice[]; + Default?: string; +} + +export interface StepFunctionDefinitionStatesTypeFail extends StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType.Fail; + Error?: string; + Cause?: string; + CausePath?: string; + ErrorPath?: string; +} + +export interface StepFunctionDefinitionStatesTypeSucceed + extends StepFunctionDefinitionStatesTypeBase { + Type: StepFunctionDefinitionStatesType.Succeed; +} + +export interface StepFunctionDefinitionStatesTypeWait extends StepFunctionDefinitionStatesTypeBase { + Next: string; + Seconds?: number; + Timestamp?: string; + SecondsPath?: string; + TimestampPath?: string; +} + +export type StepFunctionDefinitionStatesTypes = + | StepFunctionDefinitionStatesTypeTask + | StepFunctionDefinitionStatesTypePass + | StepFunctionDefinitionStatesTypeChoice + | StepFunctionDefinitionStatesTypeFail + | StepFunctionDefinitionStatesTypeSucceed + | StepFunctionDefinitionStatesTypeWait; + +export interface StepFunctionDefinition { + Comment: string; + StartAt: string; + States: { + [key: string]: StepFunctionDefinitionStatesTypes; + }; +} diff --git a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts index c026c448775..da721784b2b 100644 --- a/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/api/createApiPulumiApp.ts @@ -1,19 +1,25 @@ import * as aws from "@pulumi/aws"; import { createPulumiApp, PulumiAppParam, PulumiAppParamCallback } from "@webiny/pulumi"; import { - ApiGateway, ApiApwScheduler, + ApiBackgroundTask, ApiCloudfront, ApiFileManager, + ApiGateway, ApiGraphql, ApiMigration, ApiPageBuilder, CoreOutput, - VpcConfig, - CreateCorePulumiAppParams + CreateCorePulumiAppParams, + VpcConfig } from "~/apps"; import { applyCustomDomain, CustomDomainParams } from "../customDomain"; -import { tagResources, withCommonLambdaEnvVariables, addDomainsUrlsOutputs } from "~/utils"; +import { + addDomainsUrlsOutputs, + tagResources, + withCommonLambdaEnvVariables, + withServiceManifest +} from "~/utils"; export type ApiPulumiApp = ReturnType; @@ -71,7 +77,7 @@ export interface CreateApiPulumiAppParams { } export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = {}) => { - const app = createPulumiApp({ + const baseApp = createPulumiApp({ name: "api", path: "apps/api", config: projectAppParams, @@ -150,12 +156,6 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = } }); - const fileManager = app.addModule(ApiFileManager, { - env: { - DB_TABLE: core.primaryDynamodbTableName - } - }); - const apwScheduler = app.addModule(ApiApwScheduler, { primaryDynamodbTableArn: core.primaryDynamodbTableArn, @@ -197,6 +197,12 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = apwSchedulerEventTarget: apwScheduler.eventTarget.output }); + const fileManager = app.addModule(ApiFileManager, { + env: { + DB_TABLE: core.primaryDynamodbTableName + } + }); + const apiGateway = app.addModule(ApiGateway, { "graphql-post": { path: "/graphql", @@ -213,6 +219,11 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = method: "ANY", function: fileManager.functions.download.output.arn }, + "private-any": { + path: "/private/{path+}", + method: "ANY", + function: fileManager.functions.download.output.arn + }, "cms-post": { path: "/cms/{key+}", method: "POST", @@ -238,6 +249,8 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = applyCustomDomain(cloudfront, domains); } + const backgroundTask = app.addModule(ApiBackgroundTask); + app.addOutputs({ region: aws.config.region, cognitoUserPoolId: core.cognitoUserPoolId, @@ -250,7 +263,9 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = dynamoDbTable: core.primaryDynamodbTableName, dynamoDbElasticsearchTable: core.elasticsearchDynamodbTableName, migrationLambdaArn: migration.function.output.arn, - graphqlLambdaName: graphql.functions.graphql.output.name + graphqlLambdaName: graphql.functions.graphql.output.name, + backgroundTaskLambdaArn: backgroundTask.backgroundTask.output.arn, + backgroundTaskStepFunctionArn: backgroundTask.stepFunction.output.arn }); app.addHandler(() => { @@ -277,10 +292,24 @@ export const createApiPulumiApp = (projectAppParams: CreateApiPulumiAppParams = apiGateway, cloudfront, apwScheduler, - migration + migration, + backgroundTask }; } }); - return withCommonLambdaEnvVariables(app); + const app = withServiceManifest(withCommonLambdaEnvVariables(baseApp)); + + app.addHandler(() => { + app.addServiceManifest({ + name: "api", + manifest: { + cloudfront: { + distributionId: baseApp.resources.cloudfront.output.id + } + } + }); + }); + + return app; }; diff --git a/packages/pulumi-aws/src/apps/api/index.ts b/packages/pulumi-aws/src/apps/api/index.ts index 25659ed39ad..09987a078aa 100644 --- a/packages/pulumi-aws/src/apps/api/index.ts +++ b/packages/pulumi-aws/src/apps/api/index.ts @@ -1,4 +1,5 @@ export * from "./ApiApwScheduler"; +export * from "./ApiBackgroundTask"; export * from "./ApiCloudfront"; export * from "./ApiFileManager"; export * from "./ApiGateway"; diff --git a/packages/pulumi-aws/src/apps/common/CoreOutput.ts b/packages/pulumi-aws/src/apps/common/CoreOutput.ts index 245198606de..4175de607be 100644 --- a/packages/pulumi-aws/src/apps/common/CoreOutput.ts +++ b/packages/pulumi-aws/src/apps/common/CoreOutput.ts @@ -26,6 +26,7 @@ export const CoreOutput = createAppModule({ cognitoUserPoolArn: output["cognitoUserPoolArn"] as string, cognitoUserPoolPasswordPolicy: output["cognitoUserPoolPasswordPolicy"] as string, cognitoAppClientId: output["cognitoAppClientId"] as string, + eventBusName: output["eventBusName"] as string, eventBusArn: output["eventBusArn"] as string, // These outputs are optional, since VPC is not always enabled. vpcPublicSubnetIds: output["vpcPublicSubnetIds"] as string[] | undefined, diff --git a/packages/pulumi-aws/src/apps/core/createCorePulumiApp.ts b/packages/pulumi-aws/src/apps/core/createCorePulumiApp.ts index 0245f2f7020..4ce3e944f66 100644 --- a/packages/pulumi-aws/src/apps/core/createCorePulumiApp.ts +++ b/packages/pulumi-aws/src/apps/core/createCorePulumiApp.ts @@ -8,6 +8,8 @@ import { CoreEventBus } from "./CoreEventBus"; import { CoreFileManger } from "./CoreFileManager"; import { CoreVpc } from "./CoreVpc"; import { tagResources } from "~/utils"; +import { withServiceManifest } from "~/utils/withServiceManifest"; +import { addServiceManifestTableItem, TableDefinition } from "~/utils/addServiceManifestTableItem"; export type CorePulumiApp = ReturnType; @@ -77,7 +79,7 @@ export interface CoreAppLegacyConfig { } export function createCorePulumiApp(projectAppParams: CreateCorePulumiAppParams = {}) { - return createPulumiApp({ + const app = createPulumiApp({ name: "core", path: "apps/core", config: projectAppParams, @@ -125,7 +127,7 @@ export function createCorePulumiApp(projectAppParams: CreateCorePulumiAppParams // By doing this, we're ensuring user's adjustments are not applied to late. if (projectAppParams.pulumi) { app.addHandler(() => { - return projectAppParams.pulumi!(app as CorePulumiApp); + return projectAppParams.pulumi!(app as unknown as CorePulumiApp); }); } @@ -172,6 +174,7 @@ export function createCorePulumiApp(projectAppParams: CreateCorePulumiAppParams cognitoUserPoolArn: cognito.userPool.output.arn, cognitoUserPoolPasswordPolicy: cognito.userPool.output.passwordPolicy, cognitoAppClientId: cognito.userPoolClient.output.id, + eventBusName: eventBus.output.name, eventBusArn: eventBus.output.arn }); @@ -190,4 +193,16 @@ export function createCorePulumiApp(projectAppParams: CreateCorePulumiAppParams }; } }); + + return withServiceManifest(app, manifests => { + const dynamoTable = app.resources.dynamoDbTable; + + const table: TableDefinition = { + tableName: dynamoTable.output.name, + hashKey: dynamoTable.output.hashKey, + rangeKey: dynamoTable.output.rangeKey + }; + + manifests.forEach(manifest => addServiceManifestTableItem(app, table, manifest)); + }); } diff --git a/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts b/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts index 542929be3f1..8fca4c62d98 100644 --- a/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts +++ b/packages/pulumi-aws/src/apps/react/createReactPulumiApp.ts @@ -6,6 +6,7 @@ import { createPrivateAppBucket } from "../createAppBucket"; import { applyCustomDomain, CustomDomainParams } from "../customDomain"; import * as pulumi from "@pulumi/pulumi"; import { CoreOutput } from "../common/CoreOutput"; +import { withServiceManifest } from "~/utils/withServiceManifest"; export type ReactPulumiApp = ReturnType; @@ -43,7 +44,7 @@ export interface CreateReactPulumiAppParams { } export const createReactPulumiApp = (projectAppParams: CreateReactPulumiAppParams) => { - return createPulumiApp({ + const app = createPulumiApp({ name: projectAppParams.name, path: projectAppParams.folder, config: projectAppParams, @@ -68,7 +69,7 @@ export const createReactPulumiApp = (projectAppParams: CreateReactPulumiAppParam // By doing this, we're ensuring user's adjustments are not applied to late. if (projectAppParams.pulumi) { app.addHandler(() => { - return projectAppParams.pulumi!(app as ReactPulumiApp); + return projectAppParams.pulumi!(app as unknown as ReactPulumiApp); }); } @@ -170,4 +171,6 @@ export const createReactPulumiApp = (projectAppParams: CreateReactPulumiAppParam }; } }); + + return withServiceManifest(app); }; diff --git a/packages/pulumi-aws/src/apps/website/WebsitePrerendering.ts b/packages/pulumi-aws/src/apps/website/WebsitePrerendering.ts index 7b44018ed8a..ec7d8b7b613 100644 --- a/packages/pulumi-aws/src/apps/website/WebsitePrerendering.ts +++ b/packages/pulumi-aws/src/apps/website/WebsitePrerendering.ts @@ -4,7 +4,6 @@ import * as aws from "@pulumi/aws"; import { marshall } from "@webiny/aws-sdk/client-dynamodb"; import { PulumiApp } from "@webiny/pulumi"; -// @ts-expect-error import { getLayerArn } from "@webiny/aws-layers"; import { createLambdaRole, getCommonLambdaEnvVariables } from "../lambdaUtils"; diff --git a/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts b/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts index 3bff49691ca..2d84dd47d23 100644 --- a/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts +++ b/packages/pulumi-aws/src/apps/website/createWebsitePulumiApp.ts @@ -8,6 +8,7 @@ import { createPrerenderingService } from "./WebsitePrerendering"; import { CoreOutput, VpcConfig } from "~/apps"; import { addDomainsUrlsOutputs, tagResources, withCommonLambdaEnvVariables } from "~/utils"; import { applyTenantRouter } from "~/apps/tenantRouter"; +import { withServiceManifest } from "~/utils/withServiceManifest"; export type WebsitePulumiApp = ReturnType; @@ -305,5 +306,5 @@ export const createWebsitePulumiApp = (projectAppParams: CreateWebsitePulumiAppP } }); - return withCommonLambdaEnvVariables(app); + return withServiceManifest(withCommonLambdaEnvVariables(app)); }; diff --git a/packages/pulumi-aws/src/utils/addServiceManifestTableItem.ts b/packages/pulumi-aws/src/utils/addServiceManifestTableItem.ts new file mode 100644 index 00000000000..ba5a761485c --- /dev/null +++ b/packages/pulumi-aws/src/utils/addServiceManifestTableItem.ts @@ -0,0 +1,45 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as aws from "@pulumi/aws"; +import { PulumiApp } from "@webiny/pulumi"; +import { marshall } from "@webiny/aws-sdk/client-dynamodb"; + +export interface ServiceManifest { + name: string; + manifest: Record; +} + +export interface TableDefinition { + tableName: pulumi.Output; + hashKey: pulumi.Output; + rangeKey: pulumi.Output; +} + +export const addServiceManifestTableItem = ( + app: PulumiApp, + table: TableDefinition, + manifest: ServiceManifest +) => { + table.rangeKey.apply(res => { + new aws.dynamodb.TableItem(manifest.name, { + tableName: table.tableName, + hashKey: table.hashKey, + rangeKey: res, + item: pulumi + .all(manifest.manifest) + .apply(res => { + return pulumi.interpolate`{ + "PK": "SERVICE_MANIFEST#${app.name}#${manifest.name}", + "SK": "${app.params.run.variant || "default"}", + "GSI1_PK": "SERVICE_MANIFESTS", + "GSI1_SK": "${app.name}#${manifest.name}", + "data": { + "name": "${manifest.name}", + "manifest": ${JSON.stringify(res)} + } + }`; + }) + // We're using the native DynamoDB converter to avoid building those nested objects ourselves. + .apply(v => JSON.stringify(marshall(JSON.parse(v)))) + }); + }); +}; diff --git a/packages/pulumi-aws/src/utils/index.ts b/packages/pulumi-aws/src/utils/index.ts index 96835762f60..785babe4a1c 100644 --- a/packages/pulumi-aws/src/utils/index.ts +++ b/packages/pulumi-aws/src/utils/index.ts @@ -2,3 +2,4 @@ export * from "./tagResources"; export * from "./addDomainsUrlsOutputs"; export * from "./uploadFolderToS3"; export { withCommonLambdaEnvVariables, getCommonLambdaEnvVariables } from "./lambdaEnvVariables"; +export { withServiceManifest } from "./withServiceManifest"; diff --git a/packages/pulumi-aws/src/utils/lambdaEnvVariables.ts b/packages/pulumi-aws/src/utils/lambdaEnvVariables.ts index 56692bb1e83..7ae95a77959 100644 --- a/packages/pulumi-aws/src/utils/lambdaEnvVariables.ts +++ b/packages/pulumi-aws/src/utils/lambdaEnvVariables.ts @@ -64,22 +64,21 @@ export interface WithCommonLambdaEnvVariables { export function withCommonLambdaEnvVariables( app: T ): T & WithCommonLambdaEnvVariables { - const originalProgram = app.program; - app.program = async app => { - // We must first execute the original program, and pass in the augmented app. - const resources = await originalProgram({ - ...app, - // @ts-expect-error because currently, we don't have a way of passing in a custom app type. - setCommonLambdaEnvVariables - }); + app.decorateProgram<{ setCommonLambdaEnvVariables: typeof setCommonLambdaEnvVariables }>( + async (program, app) => { + const output = await program({ + ...app, + setCommonLambdaEnvVariables + }); - // Once the program is executed, we need to seal the variables (this will resolve the pulumi.output promise). - app.addHandler(() => { - sealEnvVariables(); - }); + // Once the program is executed, we need to seal the variables (this will resolve the pulumi.output promise). + app.addHandler(() => { + sealEnvVariables(); + }); - return resources; - }; + return output; + } + ); // Augment the original PulumiApp. return { diff --git a/packages/pulumi-aws/src/utils/withServiceManifest.ts b/packages/pulumi-aws/src/utils/withServiceManifest.ts new file mode 100644 index 00000000000..ac22f07132f --- /dev/null +++ b/packages/pulumi-aws/src/utils/withServiceManifest.ts @@ -0,0 +1,67 @@ +import merge from "lodash/merge"; +import { PulumiApp } from "@webiny/pulumi"; +import { + addServiceManifestTableItem, + ServiceManifest, + TableDefinition +} from "./addServiceManifestTableItem"; +import { CoreOutput } from "~/apps"; + +export interface WithServiceManifest { + addServiceManifest(manifest: ServiceManifest): void; +} + +interface ApplyManifests { + (manifests: ServiceManifest[]): void; +} + +const defaultApplyManifests = (app: PulumiApp, manifests: ServiceManifest[]) => { + const core = app.getModule(CoreOutput); + + const table: TableDefinition = { + tableName: core.primaryDynamodbTableName, + hashKey: core.primaryDynamodbTableHashKey, + rangeKey: core.primaryDynamodbTableRangeKey + }; + + manifests.forEach(manifest => addServiceManifestTableItem(app, table, manifest)); +}; + +/** + * Augment the given app with `addServiceManifest` functionality. + * @param {PulumiApp} app + */ +export function withServiceManifest( + app: T, + applyManifests?: ApplyManifests +): T & WithServiceManifest { + const manifests: Record = {}; + + function addServiceManifest(manifest: ServiceManifest) { + manifests[manifest.name] = merge({}, manifests[manifest.name], manifest); + } + + app.decorateProgram<{ addServiceManifest: typeof addServiceManifest }>(async (program, app) => { + const output = await program({ + ...app, + addServiceManifest + }); + + app.addHandler(() => { + if (!applyManifests) { + defaultApplyManifests(app, Object.values(manifests)); + return; + } + + applyManifests(Object.values(manifests)); + }); + + return output; + }); + + // Augment the original PulumiApp. + return { + ...app, + addServiceManifest + }; +} diff --git a/packages/pulumi-sdk/src/Pulumi.ts b/packages/pulumi-sdk/src/Pulumi.ts index 9325821426a..50ace209c63 100644 --- a/packages/pulumi-sdk/src/Pulumi.ts +++ b/packages/pulumi-sdk/src/Pulumi.ts @@ -17,6 +17,7 @@ export interface ExecaArgs { env?: { [key: string]: string | undefined; }; + [key: string]: any; } @@ -53,6 +54,7 @@ export class Pulumi { pulumiFolder: string; pulumiDownloadFolder: string; pulumiBinaryPath: string; + constructor(options: Options = {}) { this.options = options; @@ -127,7 +129,7 @@ export class Pulumi { * we need to specify the exact location of our Pulumi binaries, using the PATH environment variable, so it can correctly resolve * plugins necessary for custom resources and dynamic providers to work. */ - PATH: process.env.PATH + PATH_SEPARATOR + this.pulumiFolder + PATH: this.pulumiFolder + PATH_SEPARATOR + process.env.PATH } }; diff --git a/packages/pulumi/src/createPulumiApp.ts b/packages/pulumi/src/createPulumiApp.ts index e5777b0b6f6..1d11bdceda2 100644 --- a/packages/pulumi/src/createPulumiApp.ts +++ b/packages/pulumi/src/createPulumiApp.ts @@ -14,9 +14,11 @@ import { } from "./PulumiAppResource"; import { CreatePulumiAppParams, + ProgramDecorator, PulumiApp, PulumiAppParam, PulumiAppParamCallback, + PulumiProgram, ResourceHandler } from "~/types"; import { PulumiAppRemoteResource } from "~/PulumiAppRemoteResource"; @@ -42,6 +44,8 @@ export function createPulumiApp>( appRelativePath ); + const programDecorators: ProgramDecorator[] = []; + const app: PulumiApp = { resourceHandlers: [], handlers: [], @@ -60,11 +64,21 @@ export function createPulumiApp>( create: params.config || {}, run: {} }, + decorateProgram: cb => { + programDecorators.push(cb); + }, async run(config) { app.params.run = config; - Object.assign(app.resources, await app.program(app)); + const programOutput = programDecorators.reduce>>( + (program, decorator) => { + return app => decorator(program, app); + }, + (input: PulumiApp) => app.program(input) + ); + + Object.assign(app.resources, await programOutput(app)); for (const handler of app.handlers) { await handler(); diff --git a/packages/pulumi/src/types.ts b/packages/pulumi/src/types.ts index a68aea73247..574f3253386 100644 --- a/packages/pulumi/src/types.ts +++ b/packages/pulumi/src/types.ts @@ -14,8 +14,8 @@ export interface ResourceHandler { export type PulumiAppParamCallback = (app: PulumiApp) => T | undefined; export type PulumiAppParam = T | PulumiAppParamCallback; -export type PulumiProgram> = ( - app: PulumiApp +export type PulumiProgram> = ( + app: TApp ) => TResources | Promise; export type CreateConfig = Record; @@ -28,6 +28,10 @@ export interface CreatePulumiAppParams; } +export interface ProgramDecorator { + (program: PulumiProgram, app: TApp): ReturnType>; +} + export interface PulumiApp> { resourceHandlers: ResourceHandler[]; handlers: (() => void | Promise)[]; @@ -36,7 +40,10 @@ export interface PulumiApp> { paths: { absolute: string; relative: string; workspace: string }; name: string; - program: PulumiProgram; + decorateProgram: ( + decorator: ProgramDecorator & T, TResources> + ) => void; + program: PulumiProgram; resources: TResources; params: { create: CreateConfig; diff --git a/packages/serverless-cms-aws/handlers/common/api/fileManager/download/src/index.ts b/packages/serverless-cms-aws/handlers/common/api/fileManager/download/src/index.ts deleted file mode 100644 index 31071b2e1f9..00000000000 --- a/packages/serverless-cms-aws/handlers/common/api/fileManager/download/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; -import { createHandler } from "@webiny/handler-aws"; -import { - createDownloadFileByExactKeyPlugins, - createDownloadFileByAliasPlugins -} from "@webiny/api-file-manager/handlers/download"; - -const documentClient = getDocumentClient(); - -export const handler = createHandler({ - plugins: [ - createDownloadFileByExactKeyPlugins(), - createDownloadFileByAliasPlugins({ documentClient }) - ] -}); diff --git a/packages/serverless-cms-aws/handlers/common/api/fileManager/download/webiny.config.js b/packages/serverless-cms-aws/handlers/common/api/fileManager/download/webiny.config.js deleted file mode 100644 index f94f2382518..00000000000 --- a/packages/serverless-cms-aws/handlers/common/api/fileManager/download/webiny.config.js +++ /dev/null @@ -1,9 +0,0 @@ -const createBuildFunction = require("../../../../createBuildFunction"); -const createWatchFunction = require("../../../../createWatchFunction"); - -module.exports = { - commands: { - build: createBuildFunction({ cwd: __dirname }), - watch: createWatchFunction({ cwd: __dirname }) - } -}; diff --git a/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/src/index.ts b/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/src/index.ts deleted file mode 100644 index 950b6c9daaf..00000000000 --- a/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createHandler } from "@webiny/handler-aws/raw"; -import { createTransformFilePlugins } from "@webiny/api-file-manager/handlers/transform"; - -export const handler = createHandler({ - plugins: [createTransformFilePlugins()] -}); diff --git a/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/webiny.config.js b/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/webiny.config.js deleted file mode 100644 index 923c428b45f..00000000000 --- a/packages/serverless-cms-aws/handlers/common/api/fileManager/transform/webiny.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const createBuildFunction = require("../../../../createBuildFunction"); -const createWatchFunction = require("../../../../createWatchFunction"); - -const webpack = config => { - config.externals.push("sharp"); - return config; -}; - -module.exports = { - commands: { - build: createBuildFunction({ cwd: __dirname, overrides: { webpack } }), - watch: createWatchFunction({ cwd: __dirname, overrides: { webpack } }) - } -}; diff --git a/packages/tasks/.babelrc.js b/packages/tasks/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/tasks/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/tasks/LICENSE b/packages/tasks/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/tasks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tasks/README.md b/packages/tasks/README.md new file mode 100644 index 00000000000..f65a974c3bc --- /dev/null +++ b/packages/tasks/README.md @@ -0,0 +1,10 @@ +# @webiny/tasks +[![](https://img.shields.io/npm/dw/@webiny/tasks.svg)](https://www.npmjs.com/package/@webiny/tasks) +[![](https://img.shields.io/npm/v/@webiny/tasks.svg)](https://www.npmjs.com/package/@webiny/tasks) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +## Install +``` +yarn add @webiny/tasks +``` diff --git a/packages/tasks/__tests__/crud/definitions.test.ts b/packages/tasks/__tests__/crud/definitions.test.ts new file mode 100644 index 00000000000..f3706e46649 --- /dev/null +++ b/packages/tasks/__tests__/crud/definitions.test.ts @@ -0,0 +1,69 @@ +import { useHandler } from "~tests/helpers/useHandler"; +import { createTaskDefinition } from "~/task"; + +describe("definitions crud", () => { + const handler = useHandler({ + plugins: [ + createTaskDefinition({ + id: "testDefinitionNumber1", + title: "Test definition #1", + async run({ response }) { + return response.done("successfully ran the task #1"); + } + }), + createTaskDefinition({ + id: "testDefinitionNumber2", + title: "Test definition #2", + async run({ response }) { + return response.done("successfully ran the task #2"); + } + }), + createTaskDefinition({ + id: "testDefinitionNumber3", + title: "Test definition #3", + async run({ response }) { + return response.done("successfully ran the task #3"); + } + }) + ] + }); + + it("should get task definition", async () => { + const context = await handler.handle(); + + const definition = context.tasks.getDefinition("testDefinitionNumber1"); + expect(definition).toMatchObject({ + id: "testDefinitionNumber1", + title: "Test definition #1" + }); + }); + + it("should return null when definition does not exist", async () => { + const context = await handler.handle(); + + const definition = context.tasks.getDefinition("non-existing-definition"); + expect(definition).toBeNull(); + }); + + it("should list all definitions", async () => { + const context = await handler.handle(); + + const definitions = context.tasks.listDefinitions(); + + expect(definitions).toHaveLength(3); + expect(definitions).toMatchObject([ + { + id: "testDefinitionNumber1", + title: "Test definition #1" + }, + { + id: "testDefinitionNumber2", + title: "Test definition #2" + }, + { + id: "testDefinitionNumber3", + title: "Test definition #3" + } + ]); + }); +}); diff --git a/packages/tasks/__tests__/crud/store.test.ts b/packages/tasks/__tests__/crud/store.test.ts new file mode 100644 index 00000000000..04ef793922d --- /dev/null +++ b/packages/tasks/__tests__/crud/store.test.ts @@ -0,0 +1,184 @@ +import { useHandler } from "~tests/helpers/useHandler"; +import { createTaskDefinition } from "~/task"; +import { ITaskData, TaskDataStatus } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import WebinyError from "@webiny/error"; +import { createMockIdentity } from "~tests/mocks/identity"; + +describe("store crud", () => { + const handler = useHandler({ + plugins: [ + createTaskDefinition({ + id: "testDefinition", + title: "Test definition", + async run({ response }) { + return response.done("successfully ran the task"); + } + }) + ] + }); + + it("should return null when getting task which does not exist", async () => { + const context = await handler.handle(); + + const result = await context.tasks.getTask("non-existing-id"); + + expect(result).toBeNull(); + }); + + it("should return empty item array when listing tasks and no tasks are present", async () => { + const context = await handler.handle(); + + const result = await context.tasks.listTasks(); + + expect(result).toEqual({ + items: [], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 0 + } + }); + }); + + it("should fail on creating a task which does not have a definition", async () => { + const context = await handler.handle(); + + let result: WebinyError | null = null; + try { + await context.tasks.createTask({ + name: "My Custom Task", + definitionId: "non-existing-definition", + input: { + someValue: true, + someOtherValue: 123 + } + }); + } catch (ex) { + result = ex; + } + + expect(result).toBeInstanceOf(WebinyError); + expect(result!.message).toEqual("There is no task definition."); + expect(result!.code).toEqual("TASK_DEFINITION_ERROR"); + expect(result!.data).toEqual({ + id: "non-existing-definition" + }); + }); + + it("should fail on updating a task which does not exist", async () => { + const context = await handler.handle(); + + let result: any = null; + + try { + await context.tasks.updateTask("non-existing-id", {}); + } catch (ex) { + result = ex; + } + + expect(result).toBeInstanceOf(NotFoundError); + }); + + it("should create, update and delete a task", async () => { + const context = await handler.handle(); + + const task = await context.tasks.createTask({ + name: "My Custom Task", + definitionId: "testDefinition", + input: { + someValue: true, + someOtherValue: 123 + } + }); + const expectedCreatedTask: ITaskData = { + id: expect.any(String), + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/), + name: "My Custom Task", + definitionId: "testDefinition", + input: { + someValue: true, + someOtherValue: 123 + }, + iterations: 0, + createdBy: createMockIdentity(), + startedOn: undefined, + finishedOn: undefined, + eventResponse: undefined, + executionName: "", + taskStatus: TaskDataStatus.PENDING + }; + expect(task).toEqual(expectedCreatedTask); + + const getTaskAfterCreate = await context.tasks.getTask(task.id); + expect(getTaskAfterCreate).toEqual(expectedCreatedTask); + + const listTasksAfterCreate = await context.tasks.listTasks(); + expect(listTasksAfterCreate).toEqual({ + items: [expectedCreatedTask], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + } + }); + + const updatedTask = await context.tasks.updateTask(task.id, { + input: { + ...task.input, + someValue: false, + addedNewValue: "yes!" + } + }); + const expectedUpdatedTask: ITaskData = { + id: expect.any(String), + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/), + name: "My Custom Task", + definitionId: "testDefinition", + input: { + someValue: false, + someOtherValue: 123, + addedNewValue: "yes!" + }, + iterations: 0, + createdBy: createMockIdentity(), + startedOn: undefined, + finishedOn: undefined, + eventResponse: undefined, + executionName: "", + taskStatus: TaskDataStatus.PENDING + }; + expect(updatedTask).toEqual(expectedUpdatedTask); + + const getTaskAfterUpdate = await context.tasks.getTask(task.id); + expect(getTaskAfterUpdate).toEqual(expectedUpdatedTask); + + const listTasksAfterUpdate = await context.tasks.listTasks(); + expect(listTasksAfterUpdate).toEqual({ + items: [expectedUpdatedTask], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + } + }); + + const deletedTask = await context.tasks.deleteTask(task.id); + expect(deletedTask).toBe(true); + + const getTaskAfterDelete = await context.tasks.getTask(task.id); + expect(getTaskAfterDelete).toBeNull(); + + const listTasksAfterDelete = await context.tasks.listTasks(); + expect(listTasksAfterDelete).toEqual({ + items: [], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 0 + } + }); + }); +}); diff --git a/packages/tasks/__tests__/crud/trigger.test.ts b/packages/tasks/__tests__/crud/trigger.test.ts new file mode 100644 index 00000000000..8b91613bf6a --- /dev/null +++ b/packages/tasks/__tests__/crud/trigger.test.ts @@ -0,0 +1,61 @@ +import { useHandler } from "~tests/helpers/useHandler"; +import { createMockTaskDefinitions } from "~tests/mocks/definition"; +import { createMockIdentity } from "~tests/mocks/identity"; +import { TaskDataStatus } from "~/types"; + +jest.mock("@webiny/aws-sdk/client-eventbridge", () => { + return { + EventBridgeClient: class EventBridgeClient { + async send(cmd: any) { + return { + input: cmd.input + }; + } + }, + PutEventsCommand: class PutEventsCommand { + public readonly input: any; + + constructor(input: any) { + this.input = input; + } + } + }; +}); + +describe("trigger crud", () => { + const handler = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + + it("should trigger a task", async () => { + const context = await handler.handle(); + + const result = await context.tasks.trigger({ + definition: "myCustomTaskNumber1", + name: "A test of triggering task", + input: { + myAnotherCustomValue: "myAnotherCustomValue", + myCustomValue: "myCustomValue" + } + }); + + expect(result).toEqual({ + id: expect.toBeString(), + name: "A test of triggering task", + definitionId: "myCustomTaskNumber1", + executionName: "", + input: { + myAnotherCustomValue: "myAnotherCustomValue", + myCustomValue: "myCustomValue" + }, + iterations: 0, + taskStatus: TaskDataStatus.PENDING, + createdBy: createMockIdentity(), + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/), + startedOn: undefined, + finishedOn: undefined, + eventResponse: expect.any(Object) + }); + }); +}); diff --git a/packages/tasks/__tests__/graphql/definitions.test.ts b/packages/tasks/__tests__/graphql/definitions.test.ts new file mode 100644 index 00000000000..a8c2534a4ee --- /dev/null +++ b/packages/tasks/__tests__/graphql/definitions.test.ts @@ -0,0 +1,48 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createMockTaskDefinitions } from "~tests/mocks/definition"; + +describe("graphql - definitions", () => { + const handler = useGraphQLHandler({ + plugins: [...createMockTaskDefinitions()] + }); + + it("should list available task definitions", async () => { + const result = await handler.listDefinitions(); + + expect(result).toEqual({ + data: { + backgroundTasks: { + listDefinitions: { + data: [ + { + id: "myCustomTaskNumber1", + title: "A custom task defined via method #1", + description: "This is a description of the task #1", + fields: [] + }, + { + id: "myCustomTaskNumber2", + title: "A custom task defined via method #2", + description: "This is a description of the task #2", + fields: [] + }, + { + id: "myCustomTaskNumber3", + title: "A custom task defined via method #3", + description: "This is a description of the task #3", + fields: [ + { + fieldId: "someField", + label: "Some Field", + type: "text" + } + ] + } + ], + error: null + } + } + } + }); + }); +}); diff --git a/packages/tasks/__tests__/graphql/logs.test.ts b/packages/tasks/__tests__/graphql/logs.test.ts new file mode 100644 index 00000000000..7d263f0b82b --- /dev/null +++ b/packages/tasks/__tests__/graphql/logs.test.ts @@ -0,0 +1,157 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createMockTaskDefinitions } from "~tests/mocks/definition"; +import { useHandler } from "~tests/helpers/useHandler"; +import { ITaskLogItemType } from "~/types"; +import { createMockIdentity } from "~tests/mocks/identity"; + +describe("graphql - logs", () => { + const contextHandler = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const handler = useGraphQLHandler({ + plugins: [...createMockTaskDefinitions()] + }); + + it("should add first log entry", async () => { + const context = await contextHandler.handle(); + + const task = await context.tasks.createTask({ + name: "My Custom Task #1", + definitionId: "myCustomTaskNumber1", + input: {} + }); + + const log = await context.tasks.createLog(task, { + executionName: "someExecutionName", + iteration: 1 + }); + expect(log).toEqual({ + id: expect.any(String), + createdBy: createMockIdentity(), + createdOn: expect.toBeDateString(), + task: task.id, + iteration: 1, + executionName: "someExecutionName", + items: [] + }); + + const result = await context.tasks.getLatestLog(task.id); + expect(result).toEqual({ + id: expect.any(String), + createdBy: createMockIdentity(), + createdOn: expect.toBeDateString(), + task: task.id, + iteration: 1, + executionName: "someExecutionName", + items: [] + }); + }); + + it("should list logs", async () => { + const context = await contextHandler.handle(); + + const task = await context.tasks.createTask({ + name: "My Custom Task #1", + definitionId: "myCustomTaskNumber1", + input: {} + }); + + const log1 = await context.tasks.createLog(task, { + executionName: "someExecutionName", + iteration: 1 + }); + + const log2Item = { + message: "Log 2 item message", + type: ITaskLogItemType.INFO, + createdOn: new Date().toISOString() + }; + const log2 = await context.tasks.updateLog(log1.id, { + items: log1.items.concat(log2Item) + }); + + const log3Item = { + message: "log 3 item message", + type: ITaskLogItemType.INFO, + createdOn: new Date().toISOString() + }; + + const log3 = await context.tasks.updateLog(log1.id, { + items: log2.items.concat(log3Item) + }); + + const log4Item = { + message: "log 4 item message", + type: ITaskLogItemType.ERROR, + createdOn: new Date().toISOString(), + error: { + message: "log 4 item error message", + code: "log4ItemErrorCode", + data: { + someData: "log4data" + } + } + }; + + await context.tasks.updateLog(log1.id, { + items: log3.items.concat(log4Item) + }); + + const logResult = await context.tasks.getLatestLog(task.id); + expect(logResult).toEqual({ + id: log1.id, + createdBy: createMockIdentity(), + createdOn: expect.toBeDateString(), + items: expect.any(Array), + executionName: "someExecutionName", + iteration: 1, + task: task.id + }); + expect(logResult.items).toHaveLength(3); + + const result = await handler.listTaskLogsQuery(); + + expect(result).toEqual({ + data: { + backgroundTasks: { + listLogs: { + data: [ + { + id: expect.toBeString(), + createdBy: createMockIdentity(), + createdOn: expect.toBeDateString(), + task: { + id: task.id + }, + iteration: 1, + executionName: "someExecutionName", + items: [ + { + ...log2Item, + data: null, + error: null + }, + { + ...log3Item, + data: null, + error: null + }, + { + ...log4Item, + data: null + } + ] + } + ], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 1 + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/tasks/__tests__/graphql/tasks.test.ts b/packages/tasks/__tests__/graphql/tasks.test.ts new file mode 100644 index 00000000000..c38fc9c8302 --- /dev/null +++ b/packages/tasks/__tests__/graphql/tasks.test.ts @@ -0,0 +1,115 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createMockTaskDefinitions } from "~tests/mocks/definition"; +import { useHandler } from "~tests/helpers/useHandler"; +import { TaskDataStatus } from "~/types"; +import { createMockIdentity } from "~tests/mocks/identity"; + +describe("graphql - tasks", () => { + const contextHandler = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const handler = useGraphQLHandler({ + plugins: [...createMockTaskDefinitions()] + }); + + it("should list tasks", async () => { + const context = await contextHandler.handle(); + + await context.tasks.createTask({ + name: "My Custom Task #1", + definitionId: "myCustomTaskNumber1", + input: { + someValue: true, + someOtherValue: 123 + } + }); + + await context.tasks.createTask({ + name: "My Custom Task #2", + definitionId: "myCustomTaskNumber2", + input: { + someValue: false, + someOtherValue: 4321 + } + }); + + await context.tasks.createTask({ + name: "My Custom Task #3", + definitionId: "myCustomTaskNumber3", + input: { + someValue: "yes!", + someOtherValue: 12345678 + } + }); + + const response = await handler.listTasks(); + + expect(response).toEqual({ + data: { + backgroundTasks: { + listTasks: { + data: [ + { + name: "My Custom Task #3", + definitionId: "myCustomTaskNumber3", + input: { + someValue: "yes!", + someOtherValue: 12345678 + }, + id: expect.any(String), + taskStatus: TaskDataStatus.PENDING, + startedOn: null, + finishedOn: null, + createdBy: createMockIdentity(), + createdOn: expect.any(String), + savedOn: expect.any(String), + eventResponse: null, + logs: [] + }, + { + name: "My Custom Task #2", + definitionId: "myCustomTaskNumber2", + input: { + someValue: false, + someOtherValue: 4321 + }, + id: expect.any(String), + taskStatus: TaskDataStatus.PENDING, + startedOn: null, + finishedOn: null, + createdBy: createMockIdentity(), + createdOn: expect.any(String), + savedOn: expect.any(String), + eventResponse: null, + logs: [] + }, + { + name: "My Custom Task #1", + definitionId: "myCustomTaskNumber1", + input: { + someValue: true, + someOtherValue: 123 + }, + id: expect.any(String), + taskStatus: TaskDataStatus.PENDING, + startedOn: null, + finishedOn: null, + createdBy: createMockIdentity(), + createdOn: expect.any(String), + savedOn: expect.any(String), + eventResponse: null, + logs: [] + } + ], + meta: { + cursor: null, + hasMoreItems: false, + totalCount: 3 + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/tasks/__tests__/helpers/graphql/definitions.ts b/packages/tasks/__tests__/helpers/graphql/definitions.ts new file mode 100644 index 00000000000..f0a8a43048d --- /dev/null +++ b/packages/tasks/__tests__/helpers/graphql/definitions.ts @@ -0,0 +1,21 @@ +export const createListDefinitionsQuery = () => { + return /* GraphQL */ ` + query ListDefinitions { + backgroundTasks { + listDefinitions { + data { + id + title + description + fields + } + error { + message + code + data + } + } + } + } + `; +}; diff --git a/packages/tasks/__tests__/helpers/graphql/logs.ts b/packages/tasks/__tests__/helpers/graphql/logs.ts new file mode 100644 index 00000000000..f20cee55a40 --- /dev/null +++ b/packages/tasks/__tests__/helpers/graphql/logs.ts @@ -0,0 +1,46 @@ +export const createListTaskLogsQuery = () => { + return /* GraphQL */ ` + query ListTaskLogs( + $where: WebinyBackgroundTaskLogListWhereInput + $sort: [WebinyBackgroundTaskLogListSorter!] + $limit: Int + $after: String + ) { + backgroundTasks { + listLogs(where: $where, sort: $sort, limit: $limit, after: $after) { + data { + id + createdOn + createdBy { + id + displayName + type + } + task { + id + } + executionName + iteration + items { + message + createdOn + type + data + error + } + } + meta { + cursor + hasMoreItems + totalCount + } + error { + message + code + data + } + } + } + } + `; +}; diff --git a/packages/tasks/__tests__/helpers/graphql/tasks.ts b/packages/tasks/__tests__/helpers/graphql/tasks.ts new file mode 100644 index 00000000000..08e21627c1b --- /dev/null +++ b/packages/tasks/__tests__/helpers/graphql/tasks.ts @@ -0,0 +1,60 @@ +export const createListTasksQuery = () => { + return /* GraphQL */ ` + query ListTasks( + $where: WebinyBackgroundTaskListWhereInput + $sort: [WebinyBackgroundTaskListSorter!] + $limit: Int + $after: String + ) { + backgroundTasks { + listTasks(where: $where, sort: $sort, limit: $limit, after: $after) { + data { + id + definitionId + name + taskStatus + createdOn + savedOn + eventResponse + createdBy { + id + displayName + type + } + startedOn + finishedOn + input + logs { + id + createdOn + createdBy { + id + displayName + type + } + executionName + iteration + items { + message + createdOn + type + data + error + } + } + } + meta { + cursor + hasMoreItems + totalCount + } + error { + message + code + data + } + } + } + } + `; +}; diff --git a/packages/tasks/__tests__/helpers/helpers.ts b/packages/tasks/__tests__/helpers/helpers.ts new file mode 100644 index 00000000000..c8770544676 --- /dev/null +++ b/packages/tasks/__tests__/helpers/helpers.ts @@ -0,0 +1,72 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; + +export interface PermissionsArg { + name: string; + locales?: string[]; + rwd?: string; + pw?: string; + own?: boolean; +} + +export const identity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +const getSecurityIdentity = () => { + return identity; +}; + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "task.entry", + rwd: "rwd" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + }, + { + name: "*" + } + ]; +}; + +export const createIdentity = (identity?: SecurityIdentity) => { + if (!identity) { + return getSecurityIdentity(); + } + return identity; +}; + +export const createDummyLocales = () => { + return new ContextPlugin(async context => { + const { i18n, security } = context; + + await security.authenticate(""); + + await security.withoutAuthorization(async () => { + const [items] = await i18n.locales.listLocales({ + where: {} + }); + if (items.length > 0) { + return; + } + await i18n.locales.createLocale({ + code: "en-US", + default: true + }); + await i18n.locales.createLocale({ + code: "de-DE", + default: true + }); + }); + }); +}; diff --git a/packages/tasks/__tests__/helpers/tenancySecurity.ts b/packages/tasks/__tests__/helpers/tenancySecurity.ts new file mode 100644 index 00000000000..4994176b331 --- /dev/null +++ b/packages/tasks/__tests__/helpers/tenancySecurity.ts @@ -0,0 +1,69 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy"; +import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security"; +import { + SecurityIdentity, + SecurityPermission, + SecurityStorageOperations +} from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import { Context } from "~tests/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; + +interface Config { + setupGraphQL?: boolean; + permissions: SecurityPermission[]; + identity?: SecurityIdentity | null; +} + +export const defaultIdentity: SecurityIdentity = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ + setupGraphQL, + permissions, + identity +}: Config): Plugin[] => { + const tenancyStorage = getStorageOps("tenancy"); + const securityStorage = getStorageOps("security"); + + return [ + createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), + setupGraphQL ? createTenancyGraphQL() : null, + createSecurityContext({ storageOperations: securityStorage.storageOperations }), + setupGraphQL ? createSecurityGraphQL() : null, + new ContextPlugin(context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }), + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/tasks/__tests__/helpers/useGraphQLHandler.ts b/packages/tasks/__tests__/helpers/useGraphQLHandler.ts new file mode 100644 index 00000000000..970f31aab53 --- /dev/null +++ b/packages/tasks/__tests__/helpers/useGraphQLHandler.ts @@ -0,0 +1,161 @@ +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { createDummyLocales, createIdentity, createPermissions } from "./helpers"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import { createApiGatewayHandler } from "@webiny/handler-aws"; +import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { PluginCollection } from "@webiny/plugins/types"; +import { createBackgroundTaskContext, createBackgroundTaskGraphQL } from "~/index"; +import { createListDefinitionsQuery } from "./graphql/definitions"; +import { ApiKey } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import apiKeyAuthentication from "@webiny/api-security/plugins/apiKeyAuthentication"; +import apiKeyAuthorization from "@webiny/api-security/plugins/apiKeyAuthorization"; +import { Context } from "~tests/types"; +import { createListTasksQuery } from "~tests/helpers/graphql/tasks"; +import { createListTaskLogsQuery } from "~tests/helpers/graphql/logs"; + +export interface InvokeParams { + httpMethod?: "POST" | "GET" | "OPTIONS"; + body?: { + query: string; + variables?: Record; + }; + headers?: Record; +} + +export interface UseHandlerParams { + plugins?: PluginCollection; +} + +const tenant = { + id: "root", + name: "Root", + parent: null +}; + +export const useGraphQLHandler = (params?: UseHandlerParams) => { + const { plugins = [] } = params || {}; + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + const handler = createApiGatewayHandler({ + plugins: [ + createWcpContext(), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: false, + permissions: createPermissions(), + identity: createIdentity() + }), + { + type: "context", + name: "context-security-tenant", + async apply(context) { + context.security.getApiKeyByToken = async ( + token: string + ): Promise => { + if (!token || token !== "aToken") { + return null; + } + const apiKey = "a1234567890"; + return { + id: apiKey, + name: apiKey, + tenant: tenant.id, + permissions: [], + token, + createdBy: { + id: "test", + displayName: "test", + type: "admin" + }, + description: "test", + createdOn: new Date().toISOString(), + webinyVersion: context.WEBINY_VERSION + }; + }; + } + } as ContextPlugin, + apiKeyAuthentication({ identityType: "api-key" }), + apiKeyAuthorization({ identityType: "api-key" }), + i18nContext(), + i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + graphQLHandlerPlugins(), + createBackgroundTaskContext(), + createBackgroundTaskGraphQL(), + ...plugins + ] + }); + + const invoke = async >({ + httpMethod = "POST", + body, + headers = {}, + ...rest + }: InvokeParams): Promise => { + const response = await handler( + { + path: "/graphql", + httpMethod, + headers: { + ["x-tenant"]: "root", + ["Content-Type"]: "application/json", + ...headers + }, + body: JSON.stringify(body), + ...rest + } as unknown as APIGatewayEvent, + {} as unknown as LambdaContext + ); + // The first element is the response body, and the second is the raw response. + return JSON.parse(response.body || "{}"); + }; + + return { + invoke, + /** + * Definitions + */ + listDefinitions: async () => { + return invoke({ + body: { + query: createListDefinitionsQuery() + } + }); + }, + /** + * Tasks + */ + listTasks: async (variables: Record = {}) => { + return invoke({ + body: { + query: createListTasksQuery(), + variables + } + }); + }, + /** + * Logs + */ + listTaskLogsQuery: (variables: Record = {}) => { + return invoke({ + body: { + query: createListTaskLogsQuery(), + variables + } + }); + } + }; +}; diff --git a/packages/tasks/__tests__/helpers/useHandler.ts b/packages/tasks/__tests__/helpers/useHandler.ts new file mode 100644 index 00000000000..de5becfcd60 --- /dev/null +++ b/packages/tasks/__tests__/helpers/useHandler.ts @@ -0,0 +1,56 @@ +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { createDummyLocales, createIdentity, createPermissions } from "./helpers"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import { createRawEventHandler, createRawHandler } from "@webiny/handler-aws"; +import { LambdaContext } from "@webiny/handler-aws/types"; +import { Context } from "~tests/types"; +import { PluginCollection } from "@webiny/plugins/types"; +import { createBackgroundTaskContext } from "~/index"; + +export interface UseHandlerParams { + plugins?: PluginCollection; +} + +export const useHandler = (params?: UseHandlerParams) => { + const { plugins = [] } = params || {}; + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + const handler = createRawHandler({ + plugins: [ + createWcpContext(), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: false, + permissions: createPermissions(), + identity: createIdentity() + }), + i18nContext(), + i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + graphQLHandlerPlugins(), + createBackgroundTaskContext(), + createRawEventHandler(async ({ context }) => { + return context; + }), + ...plugins + ] + }); + + return { + handle: async () => { + return await handler({}, {} as LambdaContext); + } + }; +}; diff --git a/packages/tasks/__tests__/mocks/context.ts b/packages/tasks/__tests__/mocks/context.ts new file mode 100644 index 00000000000..1b30ff1b07a --- /dev/null +++ b/packages/tasks/__tests__/mocks/context.ts @@ -0,0 +1,48 @@ +import { PluginsContainer } from "@webiny/plugins"; +import { + Context, + ITaskData, + ITaskLog, + ITaskLogUpdateInput, + ITaskUpdateData, + IUpdateTaskResponse +} from "~/types"; +import { PartialDeep } from "type-fest"; +import { createMockTask } from "./task"; +import { createMockTaskLog } from "~tests/mocks/taskLog"; + +export const createMockContext = (params?: PartialDeep): Context => { + const getTask = async (id: string): Promise => { + return { + ...createMockTask(), + id + }; + }; + const getLog = async (id: string): Promise => { + return { + ...createMockTaskLog(await getTask("someId")), + id + }; + }; + return { + ...params, + plugins: params?.plugins || new PluginsContainer(), + tasks: { + getTask, + updateTask: async (id: string, data: ITaskUpdateData): Promise => { + const task = await getTask(id); + return { + ...task, + ...data + } as unknown as IUpdateTaskResponse; + }, + updateLog: async (id: string, data: ITaskLogUpdateInput): Promise => { + return { + ...(await getLog(id)), + ...data + }; + }, + ...params?.tasks + } + } as unknown as Context; +}; diff --git a/packages/tasks/__tests__/mocks/definition.ts b/packages/tasks/__tests__/mocks/definition.ts new file mode 100644 index 00000000000..0519d88fbc4 --- /dev/null +++ b/packages/tasks/__tests__/mocks/definition.ts @@ -0,0 +1,62 @@ +import { Context, ITaskDefinition } from "~/types"; +import { createTaskDefinition } from "~/task"; + +export const MOCK_TASK_DEFINITION_ID = "myCustomTaskDefinition"; + +export const createMockTaskDefinition = ( + definition?: Partial +): ITaskDefinition => { + return createTaskDefinition({ + id: MOCK_TASK_DEFINITION_ID, + title: "A custom task defined via method", + run: async ({ response, isCloseToTimeout, input }) => { + try { + if (isCloseToTimeout()) { + return response.continue({ + ...input + }); + } + return response.done(); + } catch (ex) { + return response.error(ex); + } + }, + ...definition + }); +}; + +export const createMockTaskDefinitions = () => { + return [ + createTaskDefinition({ + id: "myCustomTaskNumber1", + title: "A custom task defined via method #1", + description: "This is a description of the task #1", + async run({ response }) { + return response.done("successfully ran the task #1"); + } + }), + createTaskDefinition({ + id: "myCustomTaskNumber2", + title: "A custom task defined via method #2", + description: "This is a description of the task #2", + async run({ response }) { + return response.done("successfully ran the task #2"); + } + }), + createTaskDefinition({ + id: "myCustomTaskNumber3", + title: "A custom task defined via method #3", + description: "This is a description of the task #3", + async run({ response }) { + return response.done("successfully ran the task #3"); + }, + config: task => { + task.addField({ + type: "text", + label: "Some Field", + fieldId: "someField" + }); + } + }) + ]; +}; diff --git a/packages/tasks/__tests__/mocks/event.ts b/packages/tasks/__tests__/mocks/event.ts new file mode 100644 index 00000000000..bb98f046224 --- /dev/null +++ b/packages/tasks/__tests__/mocks/event.ts @@ -0,0 +1,15 @@ +import { ITaskEvent } from "~/handler/types"; +import { MOCK_TASK_DEFINITION_ID } from "~tests/mocks/definition"; + +export const createMockEvent = (event?: Partial): ITaskEvent => { + return { + webinyTaskId: "mockEventId", + tenant: "root", + locale: "en-US", + endpoint: "manage", + stateMachineId: "randomMachineId", + webinyTaskDefinitionId: MOCK_TASK_DEFINITION_ID, + executionName: "executionNameMock", + ...event + }; +}; diff --git a/packages/tasks/__tests__/mocks/identity.ts b/packages/tasks/__tests__/mocks/identity.ts new file mode 100644 index 00000000000..a04f774a173 --- /dev/null +++ b/packages/tasks/__tests__/mocks/identity.ts @@ -0,0 +1,9 @@ +import { ITaskIdentity } from "~/types"; + +export const createMockIdentity = (): ITaskIdentity => { + return { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }; +}; diff --git a/packages/tasks/__tests__/mocks/index.ts b/packages/tasks/__tests__/mocks/index.ts new file mode 100644 index 00000000000..f25dfb3cfce --- /dev/null +++ b/packages/tasks/__tests__/mocks/index.ts @@ -0,0 +1,4 @@ +export * from "./context"; +export * from "./event"; +export * from "./response"; +export * from "./runner"; diff --git a/packages/tasks/__tests__/mocks/response.ts b/packages/tasks/__tests__/mocks/response.ts new file mode 100644 index 00000000000..221f2863de9 --- /dev/null +++ b/packages/tasks/__tests__/mocks/response.ts @@ -0,0 +1,42 @@ +import { + IResponse, + IResponseContinueParams, + IResponseContinueResult, + IResponseDoneParams, + IResponseDoneResult, + IResponseErrorParams, + IResponseErrorResult +} from "~/response/abstractions"; +import { ITaskEvent } from "~/handler/types"; +import { Response } from "~/response"; + +export const createMockResponseFactory = ( + params?: Partial +): ((event: ITaskEvent) => IResponse) => { + const { done, error, continue: cont } = params || {}; + + class MockResponse extends Response { + public override continue(params: IResponseContinueParams): IResponseContinueResult { + if (cont) { + return cont(params); + } + return super.continue(params); + } + public override done(params?: IResponseDoneParams): IResponseDoneResult { + if (done) { + return done(params); + } + return super.done(params); + } + public override error(params: IResponseErrorParams): IResponseErrorResult { + if (error) { + return error(params); + } + return super.error(params); + } + } + + return (event: ITaskEvent) => { + return new MockResponse(event); + }; +}; diff --git a/packages/tasks/__tests__/mocks/runner.ts b/packages/tasks/__tests__/mocks/runner.ts new file mode 100644 index 00000000000..d5c16d347f9 --- /dev/null +++ b/packages/tasks/__tests__/mocks/runner.ts @@ -0,0 +1,31 @@ +import { ITaskRunner } from "~/runner/abstractions"; +import { Context as LambdaContext } from "aws-lambda/handler"; +import { Reply, Request } from "@webiny/handler/types"; +import { createMockContext } from "~tests/mocks/context"; +import { IResponseResult } from "~/response/abstractions"; + +export const createMockRunner = (params?: Partial): ITaskRunner => { + const { request, reply, context, lambdaContext, getRemainingTime, isCloseToTimeout } = + params || {}; + return { + context: context || createMockContext(), + isCloseToTimeout: () => { + if (isCloseToTimeout) { + return isCloseToTimeout(); + } + return false; + }, + getRemainingTime: () => { + if (getRemainingTime) { + return getRemainingTime(); + } + return 1000; + }, + lambdaContext: lambdaContext || ({} as LambdaContext), + reply: reply || ({} as Reply), + request: request || ({} as Request), + run: async () => { + return {} as IResponseResult; + } + }; +}; diff --git a/packages/tasks/__tests__/mocks/store.ts b/packages/tasks/__tests__/mocks/store.ts new file mode 100644 index 00000000000..c2749b0b487 --- /dev/null +++ b/packages/tasks/__tests__/mocks/store.ts @@ -0,0 +1,20 @@ +import { TaskManagerStore, TaskManagerStoreContext } from "~/runner/TaskManagerStore"; +import { ITaskData, ITaskDataInput, ITaskLog } from "~/types"; +import { createMockContext } from "./context"; +import { createMockTask } from "./task"; +import { createMockTaskLog } from "./taskLog"; + +interface Params { + context?: TaskManagerStoreContext; + task?: ITaskData; + taskLog?: ITaskLog; +} + +export const createMockTaskManagerStore = (params?: Params) => { + const task = params?.task || createMockTask(); + return new TaskManagerStore( + params?.context || createMockContext(), + task, + params?.taskLog || createMockTaskLog(task) + ); +}; diff --git a/packages/tasks/__tests__/mocks/task.ts b/packages/tasks/__tests__/mocks/task.ts new file mode 100644 index 00000000000..b7e8753f492 --- /dev/null +++ b/packages/tasks/__tests__/mocks/task.ts @@ -0,0 +1,38 @@ +import { ITaskData, TaskDataStatus } from "~/types"; +import { createMockIdentity } from "./identity"; +import { EventBridgeClientSendResponse } from "@webiny/aws-sdk/client-eventbridge"; +import { MOCK_TASK_DEFINITION_ID } from "~tests/mocks/definition"; + +export const createMockTaskEventResponse = (): EventBridgeClientSendResponse => { + return { + $metadata: { + httpStatusCode: 200, + requestId: "abc", + attempts: 1, + totalRetryDelay: 0 + }, + Entries: [ + { + EventId: "abcdeft" + } + ], + FailedEntryCount: 0 + }; +}; + +export const createMockTask = (task?: Partial): ITaskData => { + return { + id: "myCustomTaskDataId", + definitionId: MOCK_TASK_DEFINITION_ID, + input: {}, + name: "A custom task defined via method", + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + taskStatus: TaskDataStatus.PENDING, + createdBy: createMockIdentity(), + eventResponse: createMockTaskEventResponse(), + executionName: "", + iterations: 0, + ...task + }; +}; diff --git a/packages/tasks/__tests__/mocks/taskLog.ts b/packages/tasks/__tests__/mocks/taskLog.ts new file mode 100644 index 00000000000..cae53325dad --- /dev/null +++ b/packages/tasks/__tests__/mocks/taskLog.ts @@ -0,0 +1,18 @@ +import { ITaskData, ITaskLog } from "~/types"; +import { createMockIdentity } from "~tests/mocks/identity"; + +export const createMockTaskLog = ( + task: Pick, + input?: Partial +): ITaskLog => { + return { + id: "mock-task-log-id", + task: task.id, + iteration: 1, + executionName: "mock-execution-name", + createdOn: new Date().toISOString(), + createdBy: createMockIdentity(), + items: [], + ...input + }; +}; diff --git a/packages/tasks/__tests__/mocks/taskResponse.ts b/packages/tasks/__tests__/mocks/taskResponse.ts new file mode 100644 index 00000000000..d41d583346b --- /dev/null +++ b/packages/tasks/__tests__/mocks/taskResponse.ts @@ -0,0 +1,6 @@ +import { TaskResponse } from "~/response"; +import { IResponse, ITaskResponse } from "~/response/abstractions"; + +export const createMockTaskResponse = (response: IResponse): ITaskResponse => { + return new TaskResponse(response); +}; diff --git a/packages/tasks/__tests__/runner/taskEventValidation.test.ts b/packages/tasks/__tests__/runner/taskEventValidation.test.ts new file mode 100644 index 00000000000..bf48d1ae7ad --- /dev/null +++ b/packages/tasks/__tests__/runner/taskEventValidation.test.ts @@ -0,0 +1,304 @@ +import { TaskEventValidation } from "~/runner/TaskEventValidation"; +import { ITaskEvent } from "~/handler/types"; +import WebinyError from "@webiny/error"; + +describe("task event validation", () => { + it("should pass the validation", async () => { + const validation = new TaskEventValidation(); + + const event: ITaskEvent = { + webinyTaskId: "123webinyTaskId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + tenant: "root", + locale: "en-US", + stateMachineId: "123stateMachineId", + endpoint: "manage", + executionName: "someExecutionName" + }; + const result = validation.validate(event); + + expect(result).toEqual({ + ...event + }); + }); + + it("should fail the validation - missing webinyTaskId", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + tenant: "root", + locale: "en-US", + stateMachineId: "123stateMachineId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + endpoint: "manage", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + webinyTaskId: { + code: "invalid_type", + message: "Required", + data: { + path: ["webinyTaskId"] + } + } + } + }); + }); + + it("should fail the validation - missing webinyTaskDefinitionId", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + tenant: "root", + locale: "en-US", + stateMachineId: "123stateMachineId", + webinyTaskId: "1234", + endpoint: "manage", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + webinyTaskDefinitionId: { + code: "invalid_type", + message: "Required", + data: { + path: ["webinyTaskDefinitionId"] + } + } + } + }); + }); + + it("should fail the validation - missing tenant", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + webinyTaskId: "123webinyTaskId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + locale: "en-US", + stateMachineId: "123stateMachineId", + endpoint: "manage", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + tenant: { + code: "invalid_type", + message: "Required", + data: { + path: ["tenant"] + } + } + } + }); + }); + + it("should fail the validation - missing locale", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + webinyTaskId: "123webinyTaskId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + tenant: "root", + stateMachineId: "123stateMachineId", + endpoint: "manage", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + locale: { + code: "invalid_type", + message: "Required", + data: { + path: ["locale"] + } + } + } + }); + }); + + it("should fail the validation - missing stateMachineId", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + webinyTaskId: "123webinyTaskId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + tenant: "root", + locale: "en-US", + endpoint: "manage", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + stateMachineId: { + code: "invalid_type", + message: "Required", + data: { + path: ["stateMachineId"] + } + } + } + }); + }); + + it("should fail the validation - missing endpoint", async () => { + const validation = new TaskEventValidation(); + + const event: Omit = { + webinyTaskId: "123webinyTaskId", + webinyTaskDefinitionId: "webinyTaskDefinitionIdMockId", + tenant: "root", + locale: "en-US", + stateMachineId: "123stateMachineId", + executionName: "someExecutionName" + }; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + endpoint: { + code: "invalid_type", + message: "Required", + data: { + path: ["endpoint"] + } + } + } + }); + }); + + it("should fail the validation - all fields", async () => { + const validation = new TaskEventValidation(); + + const event: Partial = {}; + + let result: ITaskEvent | null = null; + let error: WebinyError | null = null; + try { + result = validation.validate(event); + } catch (ex) { + error = ex; + } + expect(result).toEqual(null); + expect(error!.message).toEqual("Validation failed."); + expect(error!.code).toEqual("VALIDATION_FAILED_INVALID_FIELDS"); + expect(error!.data).toEqual({ + invalidFields: { + webinyTaskId: { + code: "invalid_type", + message: "Required", + data: { + path: ["webinyTaskId"] + } + }, + webinyTaskDefinitionId: { + code: "invalid_type", + message: "Required", + data: { + path: ["webinyTaskDefinitionId"] + } + }, + tenant: { + code: "invalid_type", + message: "Required", + data: { + path: ["tenant"] + } + }, + locale: { + code: "invalid_type", + message: "Required", + data: { + path: ["locale"] + } + }, + stateMachineId: { + code: "invalid_type", + message: "Required", + data: { + path: ["stateMachineId"] + } + }, + endpoint: { + code: "invalid_type", + message: "Required", + data: { + path: ["endpoint"] + } + }, + executionName: { + code: "invalid_type", + message: "Required", + data: { + path: ["executionName"] + } + } + } + }); + }); +}); diff --git a/packages/tasks/__tests__/runner/taskManager.test.ts b/packages/tasks/__tests__/runner/taskManager.test.ts new file mode 100644 index 00000000000..5d16926df40 --- /dev/null +++ b/packages/tasks/__tests__/runner/taskManager.test.ts @@ -0,0 +1,188 @@ +import WebinyError from "@webiny/error"; +import { TaskResponseStatus } from "~/types"; +import { TaskManager } from "~/runner/TaskManager"; +import { + createMockContext, + createMockEvent, + createMockResponseFactory, + createMockRunner +} from "~tests/mocks"; +import { ResponseContinueResult, ResponseDoneResult, ResponseErrorResult } from "~/response"; +import { createMockTask } from "~tests/mocks/task"; +import { createMockTaskDefinition } from "~tests/mocks/definition"; +import { createMockTaskResponse } from "~tests/mocks/taskResponse"; +import { createMockTaskManagerStore } from "~tests/mocks/store"; +import { createMockTaskLog } from "~tests/mocks/taskLog"; + +const mockTaskInputValues = { + someInputValue: 1 +}; + +describe("task manager", () => { + const task = createMockTask({ + input: mockTaskInputValues + }); + const taskLog = createMockTaskLog(task); + + const taskDefinition = createMockTaskDefinition(); + + it("should create a task manager", async () => { + const responseFactory = createMockResponseFactory(); + const response = responseFactory( + createMockEvent({ + webinyTaskId: task.id + }) + ); + const context = createMockContext(); + const manager = new TaskManager( + createMockRunner(), + createMockContext(), + response, + createMockTaskResponse(response), + createMockTaskManagerStore({ + context, + task + }) + ); + + expect(manager).toBeDefined(); + expect(manager.run).toBeInstanceOf(Function); + }); + + it("should run a task and return continue immediately because timeout is close", async () => { + const responseFactory = createMockResponseFactory({}); + const response = responseFactory( + createMockEvent({ + webinyTaskId: task.id + }) + ); + const context = createMockContext(); + const manager = new TaskManager( + createMockRunner({ + isCloseToTimeout: () => { + return true; + } + }), + context, + response, + createMockTaskResponse(response), + createMockTaskManagerStore({ + context, + task, + taskLog + }) + ); + + const result = await manager.run(taskDefinition); + expect(result).toBeInstanceOf(ResponseContinueResult); + expect(result).toEqual({ + status: TaskResponseStatus.CONTINUE, + input: task.input, + locale: "en-US", + tenant: "root", + message: undefined, + webinyTaskId: task.id, + webinyTaskDefinitionId: taskDefinition.id, + wait: undefined + }); + }); + + it("should run a task and update the task data to a running state - mock", async () => { + const responseFactory = createMockResponseFactory({}); + const response = responseFactory( + createMockEvent({ + webinyTaskId: task.id + }) + ); + const context = createMockContext(); + const definition = createMockTaskDefinition({ + run: async params => { + return params.response.done(); + } + }); + const manager = new TaskManager( + createMockRunner(), + context, + response, + createMockTaskResponse(response), + createMockTaskManagerStore({ + context, + task, + taskLog + }) + ); + const result = await manager.run(definition); + expect(result).toBeInstanceOf(ResponseDoneResult); + expect(result).toEqual({ + status: TaskResponseStatus.DONE, + locale: "en-US", + tenant: "root", + message: undefined, + webinyTaskId: task.id, + webinyTaskDefinitionId: taskDefinition.id + }); + }); + + it("should run a task and return an error on updating task data status to running - mock", async () => { + const responseFactory = createMockResponseFactory({}); + const response = responseFactory( + createMockEvent({ + webinyTaskId: task.id + }) + ); + const context = createMockContext({ + tasks: { + updateTask: async (id, data) => { + throw new WebinyError("Error thrown on update task.", "UPDATE_TASK_ERROR", { + id, + data + }); + } + } + }); + const definition = createMockTaskDefinition({ + run: async params => { + return params.response.done(); + } + }); + const manager = new TaskManager( + createMockRunner(), + context, + responseFactory( + createMockEvent({ + webinyTaskId: task.id + }) + ), + createMockTaskResponse(response), + createMockTaskManagerStore({ + context, + task, + taskLog + }) + ); + const result = await manager.run(definition); + expect(result).toBeInstanceOf(ResponseErrorResult); + expect(result).toEqual({ + error: { + message: "Error thrown on update task.", + code: "UPDATE_TASK_ERROR", + data: { + id: "myCustomTaskDataId", + data: { + ...task, + iterations: 1, + executionName: "executionNameMock", + taskStatus: "running", + startedOn: expect.stringMatching(/^20/) + } + }, + stack: expect.any(String) + }, + status: TaskResponseStatus.ERROR, + locale: "en-US", + tenant: "root", + webinyTaskId: task.id, + webinyTaskDefinitionId: taskDefinition.id + }); + }); +}); diff --git a/packages/tasks/__tests__/runner/taskManagerStore.test.ts b/packages/tasks/__tests__/runner/taskManagerStore.test.ts new file mode 100644 index 00000000000..41a006b5fb3 --- /dev/null +++ b/packages/tasks/__tests__/runner/taskManagerStore.test.ts @@ -0,0 +1,128 @@ +import { TaskManagerStore } from "~/runner/TaskManagerStore"; +import { createMockTask } from "~tests/mocks/task"; +import { useHandler } from "~tests/helpers/useHandler"; +import { createMockTaskDefinitions } from "~tests/mocks/definition"; + +describe("task manager store", () => { + it("should get task", async () => { + const mockTask = createMockTask(); + + const { handle } = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const context = await handle(); + + const task = await context.tasks.createTask({ + ...mockTask, + definitionId: "myCustomTaskNumber1" + }); + + const store = new TaskManagerStore(context, task); + + expect(store.getTask()).toEqual(task); + }); + + it("should update task input", async () => { + const mockTask = createMockTask(); + + const { handle } = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const context = await handle(); + + const task = await context.tasks.createTask({ + ...mockTask, + definitionId: "myCustomTaskNumber1" + }); + + const store = new TaskManagerStore(context, task); + const input = { + test: "test" + }; + await store.updateInput({ ...input }); + expect(store.getInput()).toEqual(input); + expect(store.getTask()).toEqual({ + ...task, + input: { + ...task.input, + ...input + }, + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/) + }); + + await store.updateInput(input => { + return { + ...input, + anotherOne: true + }; + }); + expect(store.getInput()).toEqual({ + ...input, + anotherOne: true + }); + expect(store.getTask()).toEqual({ + ...task, + input: { + ...task.input, + ...input, + anotherOne: true + }, + createdOn: expect.stringMatching(/^20/), + savedOn: expect.stringMatching(/^20/) + }); + }); + + it("should not update input", async () => { + const mockTask = createMockTask(); + + const { handle } = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const context = await handle(); + + const task = await context.tasks.createTask({ + ...mockTask, + definitionId: "myCustomTaskNumber1" + }); + const store = new TaskManagerStore(context, task); + + await store.updateInput({ ...task.input }); + expect(store.getInput()).toEqual(task.input); + + await store.updateInput({}); + expect(store.getInput()).toEqual(task.input); + + await store.updateInput(input => { + return input; + }); + expect(store.getInput()).toEqual(task.input); + }); + + it("should update task", async () => { + const mockTask = createMockTask(); + + const { handle } = useHandler({ + plugins: [...createMockTaskDefinitions()] + }); + const context = await handle(); + + const task = await context.tasks.createTask({ + ...mockTask, + definitionId: "myCustomTaskNumber1" + }); + const store = new TaskManagerStore(context, task); + expect(store.getTask()).toEqual(task); + + await store.updateTask(() => { + return { + executionName: "a test execution name" + }; + }); + expect(store.getTask()).toEqual({ + ...task, + savedOn: expect.stringMatching(/^20/), + executionName: "a test execution name" + }); + }); +}); diff --git a/packages/tasks/__tests__/task/input.test.ts b/packages/tasks/__tests__/task/input.test.ts new file mode 100644 index 00000000000..1e860a7ad84 --- /dev/null +++ b/packages/tasks/__tests__/task/input.test.ts @@ -0,0 +1,26 @@ +import { createTaskInput } from "~/task"; + +interface MyInput { + test: boolean; + file: string; +} + +describe("task input", () => { + it("should create task input", async () => { + const input = createTaskInput({ + id: "aMockTaskType", + input: { + test: true, + file: "test.txt" + } + }); + + expect(input).toEqual({ + id: "aMockTaskType", + input: { + test: true, + file: "test.txt" + } + }); + }); +}); diff --git a/packages/tasks/__tests__/task/plugin.test.ts b/packages/tasks/__tests__/task/plugin.test.ts new file mode 100644 index 00000000000..50d5d2cfe75 --- /dev/null +++ b/packages/tasks/__tests__/task/plugin.test.ts @@ -0,0 +1,156 @@ +import WebinyError from "@webiny/error"; +import { Context } from "../types"; +import { ITaskDefinition, ITaskDefinitionField, ITaskRunParams } from "~/types"; +import { createTaskDefinition, createTaskDefinitionField } from "~/task/plugin"; +import { createMockTaskDefinition } from "~tests/mocks/definition"; + +const taskField: ITaskDefinitionField = { + fieldId: "url", + type: "text", + label: "Url", + helpText: "Enter a URL", + validation: [ + { + name: "required", + message: "Url is required." + }, + { + name: "url", + message: "Enter a valid URL." + } + ] +}; + +interface MyInput { + test: boolean; + file: string; + page: number; +} + +class MyTask implements ITaskDefinition { + public readonly id = "myCustomTask"; + public readonly title = "A custom task defined via class"; + + public fields = [ + { + ...taskField + } + ]; + + public async run({ response }: ITaskRunParams) { + return response.done(); + } + public async onDone() { + return; + } + public async onError() { + return; + } +} + +describe("task plugin", () => { + it("should properly create a task - plain object", async () => { + const task: ITaskDefinition = { + id: "myCustomTask", + title: "A custom task defined via object", + run: async ({ response, isCloseToTimeout, input }) => { + try { + if (isCloseToTimeout()) { + return response.continue({ + ...input, + page: input.page + 1 + }); + } + + return response.done(); + } catch (ex) { + return response.error(ex); + } + }, + onDone: async () => { + return; + }, + onError: async () => { + return; + }, + fields: [ + createTaskDefinitionField({ + ...taskField + }) + ] + }; + + expect(task.id).toBe("myCustomTask"); + expect(task.title).toBe("A custom task defined via object"); + expect(task.fields).toHaveLength(1); + expect(task.fields).toEqual([taskField]); + expect(task.run).toBeInstanceOf(Function); + expect(task.onDone).toBeInstanceOf(Function); + expect(task.onError).toBeInstanceOf(Function); + }); + + it("should properly create a task - via method", async () => { + const task = createTaskDefinition({ + id: "myCustomTask", + title: "A custom task defined via method", + run: async ({ response, isCloseToTimeout, input }) => { + try { + if (isCloseToTimeout()) { + return response.continue({ + ...input + }); + } + return response.done(); + } catch (ex) { + return response.error(ex); + } + }, + onDone: async () => { + return; + }, + onError: async () => { + return; + }, + config: task => { + task.addField({ + ...taskField + }); + } + }); + + expect(task.id).toBe("myCustomTask"); + expect(task.title).toBe("A custom task defined via method"); + expect(task.fields).toHaveLength(1); + expect(task.fields).toEqual([taskField]); + expect(task.run).toBeInstanceOf(Function); + expect(task.onDone).toBeInstanceOf(Function); + expect(task.onError).toBeInstanceOf(Function); + }); + + it("should properly create a task - via class", async () => { + const task = new MyTask(); + + expect(task.id).toBe("myCustomTask"); + expect(task.title).toBe("A custom task defined via class"); + expect(task.fields).toHaveLength(1); + expect(task.fields).toEqual([taskField]); + expect(task.run).toBeInstanceOf(Function); + expect(task.onDone).toBeInstanceOf(Function); + expect(task.onError).toBeInstanceOf(Function); + }); + + it("should fail on invalid task id", async () => { + let error: WebinyError | undefined; + try { + createMockTaskDefinition({ + id: "id-whichIsNotValid" + }); + } catch (ex) { + error = ex; + } + + expect(error?.message).toEqual( + `Task ID "id-whichIsNotValid" is invalid. It must be in camelCase format, for example: "myCustomTask".` + ); + }); +}); diff --git a/packages/tasks/__tests__/types.ts b/packages/tasks/__tests__/types.ts new file mode 100644 index 00000000000..1463338c0c9 --- /dev/null +++ b/packages/tasks/__tests__/types.ts @@ -0,0 +1,5 @@ +import { Context as BaseContext } from "~/types"; + +export interface Context extends BaseContext { + someMockProperty: string; +} diff --git a/packages/tasks/jest.setup.js b/packages/tasks/jest.setup.js new file mode 100644 index 00000000000..049095053ae --- /dev/null +++ b/packages/tasks/jest.setup.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const presets = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-i18n", "storage-operations"], + ["@webiny/api-security", "storage-operations"], + ["@webiny/api-tenancy", "storage-operations"] +); + +module.exports = { + ...base({ path: __dirname }, presets) +}; diff --git a/packages/tasks/package.json b/packages/tasks/package.json new file mode 100644 index 00000000000..6e9bb19cb3a --- /dev/null +++ b/packages/tasks/package.json @@ -0,0 +1,56 @@ +{ + "name": "@webiny/tasks", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Tasks", + "contributors": [ + "Bruno Zorić " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.6", + "@webiny/api": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/aws-sdk": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/pubsub": "0.0.0", + "@webiny/utils": "0.0.0", + "aws-lambda": "^1.0.7", + "deep-equal": "^2.2.3", + "lodash": "^4.17.21", + "zod": "^3.21.4" + }, + "devDependencies": { + "@babel/cli": "^7.22.6", + "@babel/core": "^7.22.8", + "@babel/preset-env": "^7.22.7", + "@babel/preset-typescript": "^7.22.5", + "@webiny/api-i18n": "0.0.0", + "@webiny/api-security": "0.0.0", + "@webiny/api-tenancy": "0.0.0", + "@webiny/api-wcp": "0.0.0", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^3.0.2", + "ttypescript": "^1.5.13", + "type-fest": "^2.19.0", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f" +} diff --git a/packages/tasks/src/context.ts b/packages/tasks/src/context.ts new file mode 100644 index 00000000000..0f0982f32e4 --- /dev/null +++ b/packages/tasks/src/context.ts @@ -0,0 +1,35 @@ +import { Plugin } from "@webiny/plugins"; +import { ContextPlugin } from "@webiny/api"; +import { Context, ITaskConfig, ITasksContextConfigObject } from "~/types"; +import { createTaskModel } from "./crud/model"; +import { createDefinitionCrud } from "./crud/definition.tasks"; +import { createTriggerTasksCrud } from "~/crud/trigger.tasks"; +import { createTaskCrud } from "./crud/crud.tasks"; + +const createConfig = (config?: ITaskConfig): ITasksContextConfigObject => { + return { + config: { + eventBusName: config?.eventBusName || String(process.env.EVENT_BUS) + } + }; +}; + +const createTasksCrud = (input?: ITaskConfig) => { + const config = createConfig(input); + const plugin = new ContextPlugin(async context => { + context.tasks = { + ...config, + ...createDefinitionCrud(context), + ...createTaskCrud(context), + ...createTriggerTasksCrud(context, config.config) + }; + }); + + plugin.name = "tasks.context"; + + return plugin; +}; + +export const createTasksContext = (input?: ITaskConfig): Plugin[] => { + return [...createTaskModel(), createTasksCrud(input)]; +}; diff --git a/packages/tasks/src/crud/createEventBridgeEvent.ts b/packages/tasks/src/crud/createEventBridgeEvent.ts new file mode 100644 index 00000000000..409bc7851aa --- /dev/null +++ b/packages/tasks/src/crud/createEventBridgeEvent.ts @@ -0,0 +1,72 @@ +import WebinyError from "@webiny/error"; +import { EventBridgeClient, PutEventsCommand } from "@webiny/aws-sdk/client-eventbridge"; +import { ITaskConfig, ITaskData } from "~/types"; +import { ITaskEventInput } from "~/handler/types"; + +interface CreateEventBridgeEventParams { + client: EventBridgeClient; + task: Pick; + tenant: string; + locale: string; + eventBusName: string; +} + +const createEventBridgeEvent = async (params: CreateEventBridgeEventParams) => { + const { client, task, tenant, locale, eventBusName } = params; + /** + * The ITaskEvent is what our handler expect to get. + * Endpoint and stateMachineId are added by the step function. + */ + const event: ITaskEventInput = { + webinyTaskId: task.id, + webinyTaskDefinitionId: task.definitionId, + tenant, + locale + }; + + const cmd = new PutEventsCommand({ + Entries: [ + { + Source: "webiny-api-tasks", + EventBusName: eventBusName, + DetailType: "WebinyBackgroundTask", + Detail: JSON.stringify(event) + } + ] + }); + try { + return await client.send(cmd); + } catch (ex) { + throw new WebinyError( + ex.message || "Could not trigger task via Event Bridge!", + ex.code || "TRIGGER_TASK_ERROR", + { + event, + ...(ex.data || {}) + } + ); + } +}; + +export interface IFactoryParams { + config: ITaskConfig; + getTenant: () => string; + getLocale: () => string; +} + +export const createEventBridgeEventFactory = (params: IFactoryParams) => { + const { config, getTenant, getLocale } = params; + const client = new EventBridgeClient({ + region: process.env.AWS_REGION + }); + const eventBusName = config.eventBusName; + return (task: ITaskData) => { + return createEventBridgeEvent({ + client, + eventBusName, + task, + tenant: getTenant(), + locale: getLocale() + }); + }; +}; diff --git a/packages/tasks/src/crud/crud.tasks.ts b/packages/tasks/src/crud/crud.tasks.ts new file mode 100644 index 00000000000..ac885240376 --- /dev/null +++ b/packages/tasks/src/crud/crud.tasks.ts @@ -0,0 +1,254 @@ +import WebinyError from "@webiny/error"; +import { + Context, + IListTaskLogParams, + IListTaskParams, + ITaskCreateData, + ITaskData, + ITaskLog, + ITaskLogCreateInput, + ITaskLogUpdateInput, + ITasksContextCrudObject, + ITaskUpdateData, + OnTaskAfterCreateTopicParams, + OnTaskAfterDeleteTopicParams, + OnTaskAfterUpdateTopicParams, + OnTaskBeforeCreateTopicParams, + OnTaskBeforeDeleteTopicParams, + OnTaskBeforeUpdateTopicParams, + TaskDataStatus +} from "~/types"; +import { WEBINY_TASK_LOG_MODEL_ID, WEBINY_TASK_MODEL_ID } from "./model"; +import { CmsEntry, CmsModel } from "@webiny/api-headless-cms/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { createTopic } from "@webiny/pubsub"; +import { remapWhere } from "./where"; + +const createRevisionId = (id: string) => { + return `${id}#0001`; +}; + +const convertToTask = (entry: CmsEntry): ITaskData => { + return { + id: entry.entryId, + createdOn: entry.createdOn, + savedOn: entry.savedOn, + createdBy: entry.createdBy, + name: entry.values.name, + definitionId: entry.values.definitionId, + input: entry.values.input, + taskStatus: entry.values.taskStatus, + executionName: entry.values.executionName || "", + eventResponse: entry.values.eventResponse, + startedOn: entry.values.startedOn, + finishedOn: entry.values.finishedOn, + iterations: entry.values.iterations + }; +}; + +const convertToLog = (entry: CmsEntry): ITaskLog => { + return { + id: entry.entryId, + createdOn: entry.createdOn, + createdBy: entry.createdBy, + executionName: entry.values.executionName, + task: entry.values.task, + iteration: entry.values.iteration, + items: entry.values.items || [] + }; +}; + +export const createTaskCrud = (context: Context): ITasksContextCrudObject => { + const onTaskBeforeCreate = createTopic("tasks.onBeforeCreate"); + const onTaskAfterCreate = createTopic("tasks.onAfterCreate"); + const onTaskBeforeUpdate = createTopic("tasks.onBeforeUpdate"); + const onTaskAfterUpdate = createTopic("tasks.onAfterUpdate"); + const onTaskBeforeDelete = createTopic("tasks.onBeforeDelete"); + const onTaskAfterDelete = createTopic("tasks.onAfterDelete"); + + const getTaskModel = async (): Promise => { + return await context.security.withoutAuthorization(async () => { + const model = await context.cms.getModel(WEBINY_TASK_MODEL_ID); + if (model) { + return model; + } + throw new WebinyError(`There is no model "${WEBINY_TASK_MODEL_ID}".`); + }); + }; + + const getLogModel = async (): Promise => { + return await context.security.withoutAuthorization(async () => { + const model = await context.cms.getModel(WEBINY_TASK_LOG_MODEL_ID); + if (model) { + return model; + } + throw new WebinyError(`There is no model "${WEBINY_TASK_LOG_MODEL_ID}".`); + }); + }; + + const getTask = async (id: string) => { + let entry: CmsEntry; + try { + entry = await context.security.withoutAuthorization(async () => { + const model = await getTaskModel(); + return await context.cms.getEntryById(model, createRevisionId(id)); + }); + } catch (ex) { + if (ex instanceof NotFoundError) { + return null; + } + throw ex; + } + if (!entry) { + return null; + } + + return convertToTask(entry as unknown as CmsEntry); + }; + + const listTasks = async (params?: IListTaskParams) => { + const [items, meta] = await context.security.withoutAuthorization(async () => { + const model = await getTaskModel(); + return await context.cms.listLatestEntries(model, { + ...params, + where: remapWhere(params?.where) + }); + }); + + return { + items: items.map(item => convertToTask(item)), + meta + }; + }; + + const createTask = async (data: ITaskCreateData) => { + const definition = context.tasks.getDefinition(data.definitionId); + if (!definition) { + throw new WebinyError(`There is no task definition.`, "TASK_DEFINITION_ERROR", { + id: data.definitionId + }); + } + + const entry = await context.security.withoutAuthorization(async () => { + const model = await getTaskModel(); + return await context.cms.createEntry(model, { + ...data, + iterations: 0, + taskStatus: TaskDataStatus.PENDING + }); + }); + return convertToTask(entry as unknown as CmsEntry); + }; + + const updateTask = async (id: string, data: ITaskUpdateData) => { + const entry = await context.security.withoutAuthorization(async () => { + const model = await getTaskModel(); + return await context.cms.updateEntry(model, createRevisionId(id), { + ...data, + savedOn: new Date().toISOString() + }); + }); + return convertToTask(entry as unknown as CmsEntry); + }; + + const deleteTask = (id: string) => { + return context.security.withoutAuthorization(async () => { + const model = await getTaskModel(); + await context.cms.deleteEntry(model, createRevisionId(id)); + return true; + }); + }; + + const createLog = async (task: Pick, data: ITaskLogCreateInput) => { + const entry = await context.security.withoutAuthorization(async () => { + const model = await getLogModel(); + + return await context.cms.createEntry(model, { + ...data, + task: task.id + }); + }); + return convertToLog(entry as unknown as CmsEntry); + }; + + const updateLog = async (id: string, data: ITaskLogUpdateInput) => { + const entry = await context.security.withoutAuthorization(async () => { + const model = await getLogModel(); + + return await context.cms.updateEntry(model, createRevisionId(id), data); + }); + return convertToLog(entry as unknown as CmsEntry); + }; + + const getLog = async (id: string): Promise => { + try { + const entry = await context.security.withoutAuthorization(async () => { + const model = await getLogModel(); + return await context.cms.getEntryById(model, id); + }); + + return convertToLog(entry as unknown as CmsEntry); + } catch (ex) { + if (ex instanceof NotFoundError) { + return null; + } + throw ex; + } + }; + + const getLatestLog = async (taskId: string): Promise => { + const entry = await context.security.withoutAuthorization(async () => { + const model = await getLogModel(); + const [items] = await context.cms.listLatestEntries(model, { + where: { + task: taskId + }, + sort: ["createdOn_DESC"], + limit: 1 + }); + const [item] = items; + if (!item) { + throw new NotFoundError(`No existing latest log found for task "${taskId}".`); + } + return item; + }); + + return convertToLog(entry as unknown as CmsEntry); + }; + + const listLogs = async (params: IListTaskLogParams) => { + const [items, meta] = await context.security.withoutAuthorization(async () => { + const model = await getLogModel(); + return await context.cms.listLatestEntries(model, { + ...params, + where: remapWhere(params.where) + }); + }); + + return { + items: items.map(item => convertToLog(item)), + meta + }; + }; + + return { + onTaskBeforeCreate, + onTaskAfterCreate, + onTaskBeforeUpdate, + onTaskAfterUpdate, + onTaskBeforeDelete, + onTaskAfterDelete, + getTask, + listTasks, + createTask, + updateTask, + deleteTask, + createLog, + updateLog, + getLog, + listLogs, + getLatestLog, + getTaskModel, + getLogModel + }; +}; diff --git a/packages/tasks/src/crud/definition.tasks.ts b/packages/tasks/src/crud/definition.tasks.ts new file mode 100644 index 00000000000..b152d5508a4 --- /dev/null +++ b/packages/tasks/src/crud/definition.tasks.ts @@ -0,0 +1,24 @@ +import { Context, ITasksContextDefinitionObject } from "~/types"; +import { TaskDefinitionPlugin } from "~/task"; + +const getTaskDefinitionPlugins = (context: Context) => { + return context.plugins.byType(TaskDefinitionPlugin.type); +}; + +export const createDefinitionCrud = (context: Context): ITasksContextDefinitionObject => { + return { + getDefinition: (id: string) => { + const plugins = getTaskDefinitionPlugins(context); + + for (const plugin of plugins) { + if (plugin.getTask().id === id) { + return plugin.getTask(); + } + } + return null; + }, + listDefinitions: () => { + return getTaskDefinitionPlugins(context).map(plugin => plugin.getTask()); + } + }; +}; diff --git a/packages/tasks/src/crud/model.ts b/packages/tasks/src/crud/model.ts new file mode 100644 index 00000000000..a84c1d7b6ef --- /dev/null +++ b/packages/tasks/src/crud/model.ts @@ -0,0 +1,280 @@ +import { CmsGroup, createCmsGroup, createCmsModel } from "@webiny/api-headless-cms"; +import { ITaskLogItemType, TaskDataStatus } from "~/types"; +import { CmsModelGroup } from "@webiny/api-headless-cms/types"; + +export const WEBINY_TASK_MODEL_ID = "webinyTask"; +export const WEBINY_TASK_LOG_MODEL_ID = "webinyTaskLog"; + +const group: CmsGroup = { + id: "webinyTaskGroup", + isPrivate: true, + name: "Webiny Task Group", + slug: "webiny-task-group", + icon: "", + description: "" +}; + +const getModelGroup = (input: Pick): CmsModelGroup => { + return { + id: input.id, + name: input.name + }; +}; + +const taskLogModelPlugin = createCmsModel({ + modelId: WEBINY_TASK_LOG_MODEL_ID, + isPrivate: true, + noValidate: true, + description: "", + name: "Webiny Task Log", + titleFieldId: "id", + group: getModelGroup(group), + layout: [], + fields: [ + { + id: "executionName", + fieldId: "executionName", + storageId: "text@executionName", + type: "text", + label: "Execution Name", + validation: [ + { + name: "required", + message: "Execution Name is required." + } + ] + }, + { + id: "task", + fieldId: "task", + storageId: "text@task", + type: "text", + label: "Task", + validation: [ + { + name: "required", + message: "Task is required." + } + ] + }, + { + id: "iteration", + fieldId: "iteration", + storageId: "number@iteration", + type: "number", + label: "Iteration", + validation: [ + { + name: "required", + message: "Iteration is required." + } + ] + }, + { + id: "items", + fieldId: "items", + storageId: "object@items", + type: "object", + label: "Items", + multipleValues: true, + validation: [ + { + name: "required", + message: "Items is required." + } + ], + settings: { + fields: [ + { + id: "message", + fieldId: "message", + storageId: "text@message", + type: "text", + label: "Message", + validation: [ + { + name: "required", + message: "Message is required." + } + ] + }, + { + id: "createdOn", + fieldId: "createdOn", + storageId: "datetime@createdOn", + type: "datetime", + label: "Created On", + validation: [ + { + name: "required", + message: "Created On is required." + } + ] + }, + { + id: "type", + fieldId: "type", + storageId: "text@type", + type: "text", + label: "Type", + predefinedValues: { + enabled: true, + values: [ + { + value: ITaskLogItemType.INFO, + label: "Info" + }, + { + value: ITaskLogItemType.ERROR, + label: "Error" + } + ] + }, + validation: [ + { + name: "required", + message: "Type is required." + } + ] + }, + { + id: "data", + fieldId: "data", + storageId: "object@data", + type: "json", + label: "Data" + }, + { + id: "error", + fieldId: "error", + storageId: "object@error", + type: "json", + label: "Error" + } + ] + } + } + ] +}); + +const taskModelPlugin = createCmsModel({ + modelId: WEBINY_TASK_MODEL_ID, + isPrivate: true, + noValidate: true, + description: "", + name: "Webiny Task", + titleFieldId: "name", + layout: [], + group: getModelGroup(group), + fields: [ + { + id: "name", + fieldId: "name", + storageId: "text@name", + type: "text", + label: "Name", + validation: [ + { + name: "required", + message: "Name is required." + } + ] + }, + { + id: "definitionId", + fieldId: "definitionId", + storageId: "text@definitionId", + type: "text", + label: "Definition ID", + validation: [ + { + name: "required", + message: "Definition ID is required." + } + ] + }, + { + id: "executionName", + fieldId: "executionName", + storageId: "text@executionName", + type: "text", + label: "Execution Name" + }, + { + id: "iterations", + fieldId: "iterations", + storageId: "number@iterations", + type: "number", + label: "Iterations" + }, + { + id: "input", + fieldId: "input", + storageId: "object@input", + type: "json", + label: "Input" + }, + { + id: "taskStatus", + fieldId: "taskStatus", + storageId: "text@taskStatus", + type: "text", + label: "Status", + predefinedValues: { + enabled: true, + values: [ + { + value: TaskDataStatus.PENDING, + label: "Pending" + }, + { + value: TaskDataStatus.RUNNING, + label: "Running" + }, + { + value: TaskDataStatus.FAILED, + label: "Failed" + }, + { + value: TaskDataStatus.SUCCESS, + label: "Success" + }, + { + value: TaskDataStatus.ABORTED, + label: "Aborted" + } + ] + }, + settings: { + defaultValue: TaskDataStatus.PENDING + } + }, + { + id: "startedOn", + fieldId: "startedOn", + storageId: "datetime@startedOn", + type: "datetime", + label: "Started On" + }, + { + id: "finishedOn", + fieldId: "finishedOn", + storageId: "datetime@finishedOn", + type: "datetime", + label: "Finished On" + }, + { + id: "eventResponse", + fieldId: "eventResponse", + storageId: "object@eventResponse", + type: "json", + label: "Event Response" + } + ] +}); +export const taskModel = taskModelPlugin.contentModel; +export const taskLogModel = taskLogModelPlugin.contentModel; + +export const createTaskModel = () => { + return [createCmsGroup(group), taskModelPlugin, taskLogModelPlugin]; +}; diff --git a/packages/tasks/src/crud/trigger.tasks.ts b/packages/tasks/src/crud/trigger.tasks.ts new file mode 100644 index 00000000000..209e8c4d8c8 --- /dev/null +++ b/packages/tasks/src/crud/trigger.tasks.ts @@ -0,0 +1,137 @@ +import WebinyError from "@webiny/error"; +import { + Context, + ITaskAbortParams, + ITaskConfig, + ITaskCreateData, + ITaskData, + ITaskDataInput, + ITaskLog, + ITaskLogItemType, + ITasksContextTriggerObject, + ITaskTriggerParams, + TaskDataStatus +} from "~/types"; +import { createEventBridgeEventFactory } from "~/crud/createEventBridgeEvent"; +import { NotFoundError } from "@webiny/handler-graphql"; + +export const createTriggerTasksCrud = ( + context: Context, + config: ITaskConfig +): ITasksContextTriggerObject => { + const getTenant = (): string => { + return context.tenancy.getCurrentTenant().id; + }; + const getLocale = (): string => { + return context.cms.getLocale().code; + }; + const createEventBridgeEvent = createEventBridgeEventFactory({ + config, + getTenant, + getLocale + }); + + return { + trigger: async ( + params: ITaskTriggerParams + ): Promise> => { + const { definition: id, input: inputValues, name } = params; + const definition = context.tasks.getDefinition(id); + if (!definition) { + throw new WebinyError(`Task definition was not found!`, "TASK_DEFINITION_ERROR", { + id + }); + } + const input: ITaskCreateData = { + name: name || definition.title, + definitionId: id, + input: inputValues || ({} as T) + }; + if (definition.onBeforeTrigger) { + await definition.onBeforeTrigger({ + context, + input: input.input + }); + } + + const task = await context.tasks.createTask(input); + + let event: Record | null = null; + try { + event = await createEventBridgeEvent(task); + + if (!event) { + throw new WebinyError( + `Could not create the Event Bridge Event!`, + "CREATE_EVENT_BRIDGE_EVENT_ERROR", + { + task + } + ); + } + } catch (ex) { + /** + * In case of failure to create the Event Bridge Event, we need to delete the task that was meant to be created. + * TODO maybe we can leave the task and update it as failed - with event bridge error? + */ + await context.tasks.deleteTask(task.id); + throw ex; + } + return await context.tasks.updateTask(task.id, { + eventResponse: event + }); + }, + abort: async (params: ITaskAbortParams): Promise => { + const task = await context.tasks.getTask(params.id); + if (!task) { + throw new NotFoundError(`Task "${params.id}" was not found!`); + } + /** + * We should only be able to abort a task which is pending or running + */ + if ( + [TaskDataStatus.PENDING, TaskDataStatus.RUNNING].includes(task.taskStatus) === false + ) { + throw new WebinyError( + `Cannot abort a task that is not pending or running!`, + "TASK_ABORT_ERROR", + { + id: params.id, + status: task.taskStatus + } + ); + } + let taskLog: ITaskLog | null = null; + try { + taskLog = await context.tasks.getLatestLog(task.id); + } catch (ex) {} + if (!taskLog) { + taskLog = await context.tasks.createLog(task, { + iteration: 1, + executionName: task.executionName + }); + } + try { + const updatedTask = await context.tasks.updateTask(task.id, { + taskStatus: TaskDataStatus.ABORTED + }); + await context.tasks.updateLog(taskLog.id, { + items: taskLog.items.concat([ + { + message: params.message || "Task aborted.", + type: ITaskLogItemType.INFO, + createdOn: new Date().toISOString() + } + ]) + }); + + return updatedTask; + } catch (ex) { + throw new WebinyError(`Could not abort the task!`, "TASK_ABORT_ERROR", { + id: params.id, + message: ex.message + }); + } + } + }; +}; diff --git a/packages/tasks/src/crud/where.ts b/packages/tasks/src/crud/where.ts new file mode 100644 index 00000000000..45fae51419e --- /dev/null +++ b/packages/tasks/src/crud/where.ts @@ -0,0 +1,23 @@ +const maps: Record = { + id: "entryId", + id_in: "entryId_in", + id_not: "entryId_not", + id_not_in: "entryId_not_in" +}; + +export const remapWhere = >(where?: T): T | undefined => { + if (!where) { + return undefined; + } + const result: T = { ...where }; + for (const key in maps) { + const value = result[key]; + delete result[key]; + if (value === undefined) { + continue; + } + const newKey = maps[key]; + result[newKey as keyof T] = value; + } + return result; +}; diff --git a/packages/tasks/src/graphql/checkPermissions.ts b/packages/tasks/src/graphql/checkPermissions.ts new file mode 100644 index 00000000000..d16eb96b8c4 --- /dev/null +++ b/packages/tasks/src/graphql/checkPermissions.ts @@ -0,0 +1,48 @@ +import { NotAuthorizedError } from "@webiny/api-security"; +import { Context, TaskPermission } from "~/types"; + +/** + * @throws + */ +export const checkPermissions = async ( + context: Context, + check: { rwd?: string } = {} +): Promise => { + const taskPermissions = await context.security.getPermissions("tasks"); + + const relevant = taskPermissions.filter(current => { + if (check.rwd && !hasRwd(current, check.rwd)) { + return false; + } + + return true; + }); + + if (relevant.length === 0) { + throw new NotAuthorizedError(); + } +}; + +const hasRwd = (permissions: TaskPermission | TaskPermission[], rwd: string): boolean => { + if (!Array.isArray(permissions)) { + permissions = [permissions]; + } + + if (!rwd) { + return true; + } + + // Is there a permission that doesn't restrict RWD permissions, that means all RWD permissions are allowed. + const permissionWithoutRwdRestrictions = permissions.some(permission => { + return typeof permission.rwd !== "string"; + }); + + if (permissionWithoutRwdRestrictions) { + return true; + } + + // If there is no permission that doesn't restrict RWD permissions, that means we need to check if the RWD. + return permissions.some(permission => { + return permission.rwd && permission.rwd.includes(rwd); + }); +}; diff --git a/packages/tasks/src/graphql/index.ts b/packages/tasks/src/graphql/index.ts new file mode 100644 index 00000000000..1e0495d2e8a --- /dev/null +++ b/packages/tasks/src/graphql/index.ts @@ -0,0 +1,374 @@ +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { renderSortEnum } from "@webiny/api-headless-cms/utils/renderSortEnum"; +import { ContextPlugin } from "@webiny/handler"; +import { + Context, + IListTaskLogParams, + IListTaskParams, + ITaskData, + ITaskDefinition, + ITaskLog +} from "~/types"; +import { renderListFilterFields } from "@webiny/api-headless-cms/utils/renderListFilterFields"; +import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords"; +import { emptyResolver, resolve, resolveList } from "./utils"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; +import { checkPermissions } from "./checkPermissions"; + +interface IGetTaskQueryParams { + id: string; +} + +interface IAbortTaskMutationParams { + id: string; + message?: string; +} + +interface ITriggerTaskMutationParams { + name?: string; + definition: string; + input?: Record; +} + +interface IDeleteTaskMutationParams { + id: string; +} + +const createWebinyBackgroundTaskDefinitionEnum = (definitions: ITaskDefinition[]): string => { + if (definitions.length === 0) { + return "Empty"; + } + return definitions.map(definition => definition.id).join("\n"); +}; + +export const createGraphQL = () => { + const plugin = new ContextPlugin(async context => { + if (!context.tenancy.getCurrentTenant()) { + return; + } else if (!context.i18n.getDefaultLocale()) { + return; + } + + const taskModel = await context.tasks.getTaskModel(); + const logModel = await context.tasks.getLogModel(); + + const models = await context.security.withoutAuthorization(async () => { + return (await context.cms.listModels()).filter(model => { + if (model.fields.length === 0) { + return false; + } else if (model.isPrivate) { + return false; + } + return true; + }); + }); + const fieldTypePlugins = createFieldTypePluginRecords(context.plugins); + + const taskFields = renderFields({ + models, + model: taskModel, + fields: taskModel.fields, + type: "manage", + fieldTypePlugins + }); + + const logFields = renderFields({ + models, + model: logModel, + fields: logModel.fields.filter(field => field.fieldId !== "task"), + type: "manage", + fieldTypePlugins + }); + + const listTasksFilterFieldsRender = renderListFilterFields({ + model: taskModel, + fields: taskModel.fields, + type: "manage", + fieldTypePlugins, + excludeFields: ["entryId"] + }); + + const listLogsFilterFieldsRender = renderListFilterFields({ + model: logModel, + fields: logModel.fields, + type: "manage", + fieldTypePlugins, + excludeFields: ["entryId"] + }); + + const sortTasksEnumRender = renderSortEnum({ + model: taskModel, + fields: taskModel.fields, + fieldTypePlugins, + sorterPlugins: [] + }); + + const sortLogsEnumRender = renderSortEnum({ + model: logModel, + fields: logModel.fields, + fieldTypePlugins, + sorterPlugins: [] + }); + + const taskDefinitions = context.tasks.listDefinitions(); + + const plugin = new GraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type WebinyBackgroundTaskError { + message: String + code: String + data: JSON + stack: String + } + + ${taskFields.map(f => f.typeDefs).join("\n")} + ${logFields.map(f => f.typeDefs).join("\n")} + + type WebinyBackgroundTask { + id: String! + createdOn: DateTime! + savedOn: DateTime + createdBy: WebinyBackgroundTaskIdentity! + logs( + where: WebinyBackgroundTaskLogListWhereInput + limit: Number + sort: [WebinyBackgroundTaskLogListSorter!] + ): [WebinyBackgroundTaskLog!]! + ${taskFields.map(f => f.fields).join("\n")} + } + + type WebinyBackgroundTaskResponse { + data: WebinyBackgroundTask + error: WebinyBackgroundTaskError + } + + type WebinyBackgroundTaskMeta { + cursor: String + hasMoreItems: Boolean! + totalCount: Int! + } + + type WebinyBackgroundTaskListResponse { + data: [WebinyBackgroundTask!] + meta: WebinyBackgroundTaskMeta + error: WebinyBackgroundTaskError + } + + type WebinyBackgroundTaskLog { + id: String! + createdOn: DateTime! + createdBy: WebinyBackgroundTaskIdentity! + task: WebinyBackgroundTask! + ${logFields.map(f => f.fields).join("\n")} + } + + type WebinyBackgroundTaskLogListResponse { + data: [WebinyBackgroundTaskLog!] + meta: WebinyBackgroundTaskMeta + error: WebinyBackgroundTaskError + } + + type WebinyBackgroundTaskDefinition { + id: String! + title: String! + description: String + fields: JSON + } + + type WebinyBackgroundTaskListDefinitionsResponse { + data: [WebinyBackgroundTaskDefinition!] + error: WebinyBackgroundTaskError + } + + type WebinyBackgroundTaskIdentity { + id: String! + displayName: String! + type: String + } + + type WebinyBackgroundTaskTriggerResponse { + data: WebinyBackgroundTask + error: WebinyBackgroundTaskError + } + + type WebinyBackgroundTaskDeleteResponse { + data: Boolean + error: WebinyBackgroundTaskError + } + + input WebinyBackgroundTaskListWhereInput { + ${listTasksFilterFieldsRender} + } + + input WebinyBackgroundTaskLogListWhereInput { + ${listLogsFilterFieldsRender} + } + + enum WebinyBackgroundTaskListSorter { + ${sortTasksEnumRender} + } + + enum WebinyBackgroundTaskLogListSorter { + ${sortLogsEnumRender} + } + + type WebinyBackgroundTaskQuery { + _empty: String + } + + type WebinyBackgroundTaskMutation { + _empty: String + } + + enum WebinyBackgroundTaskDefinitionEnum { + ${createWebinyBackgroundTaskDefinitionEnum(taskDefinitions)} + } + + extend type Query { + backgroundTasks: WebinyBackgroundTaskQuery + } + + extend type Mutation { + backgroundTasks: WebinyBackgroundTaskMutation + } + + extend type WebinyBackgroundTaskQuery { + getTask(id: ID!): WebinyBackgroundTaskResponse! + listTasks( + where: WebinyBackgroundTaskListWhereInput + sort: [WebinyBackgroundTaskListSorter!] + limit: Int + after: String + search: String + ): WebinyBackgroundTaskListResponse! + listDefinitions: WebinyBackgroundTaskListDefinitionsResponse! + + listLogs( + where: WebinyBackgroundTaskLogListWhereInput + sort: [WebinyBackgroundTaskLogListSorter!] + limit: Int + after: String + search: String + ): WebinyBackgroundTaskLogListResponse! + } + + extend type WebinyBackgroundTaskMutation { + triggerTask(definition: WebinyBackgroundTaskDefinitionEnum!, input: JSON, name: String): WebinyBackgroundTaskTriggerResponse! + abortTask(id: ID!, message: String): WebinyBackgroundTaskResponse! + deleteTask(id: ID!): WebinyBackgroundTaskDeleteResponse! + } + `, + resolvers: { + Query: { + backgroundTasks: emptyResolver + }, + Mutation: { + backgroundTasks: emptyResolver + }, + WebinyBackgroundTaskQuery: { + /** + * We need to think of a way to pass the args type to the resolver without assigning it directly. + */ + // @ts-expect-error + getTask: async (_, args: IGetTaskQueryParams, context) => { + return resolve(async () => { + await checkPermissions(context, { + rwd: "r" + }); + return await context.tasks.getTask(args.id); + }); + }, + listTasks: async (_, args: IListTaskParams, context) => { + return resolveList(async () => { + await checkPermissions(context, { + rwd: "r" + }); + return await context.tasks.listTasks(args); + }); + }, + listDefinitions: async (_, __, context) => { + return resolve(async () => { + await checkPermissions(context, { + rwd: "r" + }); + return context.tasks.listDefinitions(); + }); + }, + listLogs: async (_, args: IListTaskLogParams, context) => { + return resolveList(async () => { + await checkPermissions(context, { + rwd: "r" + }); + return await context.tasks.listLogs(args); + }); + } + }, + WebinyBackgroundTaskMutation: { + /** + * We need to think of a way to pass the args type to the resolver without assigning it directly. + */ + // @ts-expect-error + abortTask: async (_, args: IAbortTaskMutationParams, context) => { + await checkPermissions(context, { + rwd: "w" + }); + return resolve(async () => { + return await context.tasks.abort(args); + }); + }, + /** + * We need to think of a way to pass the args type to the resolver without assigning it directly. + */ + // @ts-expect-error + triggerTask: async (_, args: ITriggerTaskMutationParams, context) => { + await checkPermissions(context, { + rwd: "w" + }); + return resolve(async () => { + return await context.tasks.trigger(args); + }); + }, + /** + * We need to think of a way to pass the args type to the resolver without assigning it directly. + */ + // @ts-expect-error + deleteTask: async (_, args: IDeleteTaskMutationParams, context) => { + await checkPermissions(context, { + rwd: "d" + }); + return resolve(async () => { + return await context.tasks.deleteTask(args.id); + }); + } + }, + /** + * Custom resolvers for fields + */ + WebinyBackgroundTask: { + logs: async (parent: ITaskData, args: IListTaskLogParams, context) => { + const { items } = await context.tasks.listLogs({ + sort: ["createdBy_ASC"], + limit: 10000, + ...args, + where: { + ...args?.where, + task: parent.id + } + }); + return items; + } + }, + WebinyBackgroundTaskLog: { + task: async (parent: ITaskLog, _, context) => { + return await context.tasks.getTask(parent.task); + } + } + } + }); + context.plugins.register(plugin); + }); + + plugin.name = "tasks.graphql"; + + return plugin; +}; diff --git a/packages/tasks/src/graphql/utils.ts b/packages/tasks/src/graphql/utils.ts new file mode 100644 index 00000000000..bf87a754fe0 --- /dev/null +++ b/packages/tasks/src/graphql/utils.ts @@ -0,0 +1,30 @@ +import { ErrorResponse, ListErrorResponse, ListResponse, Response } from "@webiny/handler-graphql"; +import { CmsEntryMeta } from "@webiny/api-headless-cms/types"; + +export const emptyResolver = () => ({}); + +interface ResolveCallable { + (): Promise; +} + +export const resolve = async (fn: ResolveCallable) => { + try { + return new Response(await fn()); + } catch (e) { + return new ErrorResponse(e); + } +}; + +interface IListResult { + items: any[]; + meta: CmsEntryMeta; +} + +export const resolveList = async (fn: ResolveCallable) => { + try { + const result = (await fn()) as IListResult; + return new ListResponse(result.items, result.meta); + } catch (e) { + return new ListErrorResponse(e); + } +}; diff --git a/packages/tasks/src/handler/index.ts b/packages/tasks/src/handler/index.ts new file mode 100644 index 00000000000..44697f79c94 --- /dev/null +++ b/packages/tasks/src/handler/index.ts @@ -0,0 +1,78 @@ +import { createHandler as createBaseHandler } from "@webiny/handler"; +import { registerDefaultPlugins } from "@webiny/handler-aws/plugins"; +import { execute } from "@webiny/handler-aws/execute"; +import { HandlerFactoryParams } from "@webiny/handler-aws/types"; +import { APIGatewayProxyResult } from "aws-lambda"; +import { Context as LambdaContext } from "aws-lambda/handler"; +import { Context, TaskResponseStatus } from "~/types"; +import { ITaskEvent } from "~/handler/types"; +import { TaskRunner } from "~/runner"; +import WebinyError from "@webiny/error"; + +export interface HandlerCallable { + (event: ITaskEvent, context: LambdaContext): Promise; +} + +export type HandlerParams = HandlerFactoryParams; + +const url = "/webiny-background-task-event"; + +export const createHandler = (params: HandlerParams): HandlerCallable => { + return async (event, context) => { + const app = createBaseHandler({ + ...params, + options: { + logger: params.debug === true, + ...(params.options || {}) + } + }); + + registerDefaultPlugins(app.webiny); + + app.addHook("preSerialization", async (_, __, payload: Record) => { + if (!payload.body) { + return payload; + } + return payload.body; + }); + + app.setErrorHandler(async (error, _, reply) => { + app.__webiny_raw_result = { + error: { + message: error.message, + code: error.code, + data: error.data + }, + status: TaskResponseStatus.ERROR + }; + return reply.send(); + }); + + app.post(url, async (request, reply) => { + const handler = new TaskRunner( + context, + request, + reply, + /** + * We can safely cast because we know that the context is of type tasks/Context + */ + app.webiny as Context + ); + + app.__webiny_raw_result = await handler.run(event); + return reply.send({}); + }); + return execute({ + app, + url, + payload: { + ...event, + headers: { + ["x-tenant"]: event.tenant, + ["x-webiny-cms-endpoint"]: event.endpoint, + ["x-webiny-cms-locale"]: event.locale + } + } + }); + }; +}; diff --git a/packages/tasks/src/handler/register.ts b/packages/tasks/src/handler/register.ts new file mode 100644 index 00000000000..4b5a1b8b190 --- /dev/null +++ b/packages/tasks/src/handler/register.ts @@ -0,0 +1,16 @@ +import { registry } from "@webiny/handler-aws/registry"; +import { createHandler, HandlerParams } from "./index"; +import { createSourceHandler } from "@webiny/handler-aws"; +import { IIncomingEvent, ITaskEvent } from "./types"; + +const handler = createSourceHandler, HandlerParams>({ + name: "handler-webiny-background-task", + canUse: event => { + return !!event.payload?.webinyTaskId; + }, + handle: async ({ params, event, context }) => { + return createHandler(params)(event.payload, context); + } +}); + +registry.register(handler); diff --git a/packages/tasks/src/handler/types.ts b/packages/tasks/src/handler/types.ts new file mode 100644 index 00000000000..5315d312d03 --- /dev/null +++ b/packages/tasks/src/handler/types.ts @@ -0,0 +1,21 @@ +export interface IIncomingEvent { + name: string; + payload: TEvent; +} + +export interface ITaskEventInput { + tenant: string; + locale: string; + webinyTaskId: string; + webinyTaskDefinitionId: string; +} + +export interface ITaskEvent { + tenant: string; + locale: string; + endpoint: string; + webinyTaskId: string; + webinyTaskDefinitionId: string; + executionName: string; + stateMachineId: string; +} diff --git a/packages/tasks/src/index.ts b/packages/tasks/src/index.ts new file mode 100644 index 00000000000..9d4f3c0eb57 --- /dev/null +++ b/packages/tasks/src/index.ts @@ -0,0 +1,15 @@ +import "./handler/register"; +import { Plugin } from "@webiny/plugins/types"; +import { createGraphQL } from "~/graphql"; +import { createTasksContext } from "./context"; +import { ITaskConfig } from "./types"; + +export const createBackgroundTaskGraphQL = (): Plugin[] => { + return [createGraphQL()]; +}; +export const createBackgroundTaskContext = (config?: ITaskConfig): Plugin[] => { + return createTasksContext(config); +}; + +export * from "./task"; +export * from "./types"; diff --git a/packages/tasks/src/response/DatabaseResponse.ts b/packages/tasks/src/response/DatabaseResponse.ts new file mode 100644 index 00000000000..683edb2c059 --- /dev/null +++ b/packages/tasks/src/response/DatabaseResponse.ts @@ -0,0 +1,157 @@ +import { TaskDataStatus, TaskResponseStatus } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { + IResponse, + IResponseAsync, + IResponseContinueParams, + IResponseContinueResult, + IResponseDoneParams, + IResponseDoneResult, + IResponseErrorParams, + IResponseErrorResult, + IResponseResult +} from "./abstractions"; +import { ITaskManagerStore } from "~/runner/abstractions"; + +export class DatabaseResponse implements IResponseAsync { + public readonly response: IResponse; + + private readonly store: ITaskManagerStore; + + public constructor(response: IResponse, store: ITaskManagerStore) { + this.response = response; + this.store = store; + } + + public from(result: IResponseResult): Promise { + switch (result.status) { + case TaskResponseStatus.DONE: + return this.done(result); + case TaskResponseStatus.CONTINUE: + return this.continue(result); + case TaskResponseStatus.ERROR: + return this.error(result); + case TaskResponseStatus.ABORTED: + return this.aborted(); + } + } + + public async done(params: IResponseDoneParams): Promise { + let message = params.message; + try { + await this.store.updateTask({ + taskStatus: TaskDataStatus.SUCCESS, + finishedOn: new Date().toISOString() + }); + await this.store.addInfoLog({ + message: message || "Task done." + }); + } catch (ex) { + message = `Task done, but failed to update task log. (${ex.message || "unknown"})`; + } + /** + * Default behavior is to return the done response. + */ + return this.response.done({ + ...params, + message + }); + } + + public async aborted() { + return this.response.aborted(); + } + + public async continue( + params: IResponseContinueParams + ): Promise { + try { + const task = this.store.getTask(); + await this.store.updateTask({ + input: { + ...task.input, + ...params.input + }, + taskStatus: TaskDataStatus.RUNNING + }); + await this.store.addInfoLog({ + message: "Task continuing.", + data: params.input + }); + } catch (ex) { + /** + * If task was not found, we just return the error. + */ + if (ex instanceof NotFoundError) { + return this.response.error({ + error: { + message: ex.message || `Task not found.`, + code: ex.code || "TASK_NOT_FOUND", + data: { + ...ex.data, + input: this.store.getInput() + } + } + }); + } + /** + * Otherwise, we store the error and return it... + */ + return this.error({ + error: { + message: `Failed to update task input: ${ex.message || "unknown error"}`, + code: ex.code || "TASK_UPDATE_ERROR" + } + }); + } + /** + * Default behavior is to return the continue response. + */ + return this.response.continue(params); + } + + public async error(params: IResponseErrorParams): Promise { + try { + await this.store.updateTask({ + taskStatus: TaskDataStatus.FAILED, + finishedOn: new Date().toISOString() + }); + await this.store.addErrorLog({ + message: params.error.message, + data: this.store.getInput(), + error: { + code: params.error.code, + message: params.error.message, + data: params.error.data + } + }); + } catch (ex) { + return this.response.error({ + ...params, + error: { + ...params.error, + message: ex.message || params.error.message, + code: ex.code || params.error.code, + data: { + ...params.error.data, + ...ex.data, + input: this.store.getInput() + } + } + }); + } + /** + * Default behavior is to return the error response. + */ + return this.response.error({ + ...params, + error: { + ...params.error, + data: { + ...params.error.data, + input: this.store.getInput() + } + } + }); + } +} diff --git a/packages/tasks/src/response/Response.ts b/packages/tasks/src/response/Response.ts new file mode 100644 index 00000000000..d95840ace2e --- /dev/null +++ b/packages/tasks/src/response/Response.ts @@ -0,0 +1,87 @@ +import { ITaskEvent } from "~/handler/types"; +import { TaskResponseStatus } from "~/types"; +import { + IResponse, + IResponseAbortedResult, + IResponseContinueParams, + IResponseContinueResult, + IResponseDoneParams, + IResponseDoneResult, + IResponseError, + IResponseErrorParams, + IResponseErrorResult, + IResponseFromParams, + IResponseResult +} from "./abstractions"; +import { ResponseContinueResult } from "~/response/ResponseContinueResult"; +import { ResponseDoneResult } from "~/response/ResponseDoneResult"; +import { ResponseErrorResult } from "~/response/ResponseErrorResult"; +import { ResponseAbortedResult } from "./ResponseAbortedResult"; + +const transformError = (error: IResponseError): IResponseError => { + return { + message: error.message, + code: error.code, + data: error.data, + stack: error.stack + }; +}; + +export class Response implements IResponse { + public readonly event: ITaskEvent; + + public constructor(event: ITaskEvent) { + this.event = event; + } + + public from(params: IResponseFromParams): IResponseResult { + switch (params.status) { + case TaskResponseStatus.DONE: + return this.done(params); + case TaskResponseStatus.CONTINUE: + return this.continue(params); + case TaskResponseStatus.ERROR: + return this.error(params); + } + } + + public continue(params: IResponseContinueParams): IResponseContinueResult { + return new ResponseContinueResult({ + input: params.input, + webinyTaskId: params?.webinyTaskId || this.event.webinyTaskId, + webinyTaskDefinitionId: this.event.webinyTaskDefinitionId, + tenant: params?.tenant || this.event.tenant, + locale: params?.locale || this.event.locale, + wait: params.wait + }); + } + + public done(params?: IResponseDoneParams): IResponseDoneResult { + return new ResponseDoneResult({ + webinyTaskId: params?.webinyTaskId || this.event.webinyTaskId, + webinyTaskDefinitionId: this.event.webinyTaskDefinitionId, + tenant: params?.tenant || this.event.tenant, + locale: params?.locale || this.event.locale, + message: params?.message + }); + } + + public aborted(): IResponseAbortedResult { + return new ResponseAbortedResult({ + webinyTaskId: this.event.webinyTaskId, + webinyTaskDefinitionId: this.event.webinyTaskDefinitionId, + tenant: this.event.tenant, + locale: this.event.locale + }); + } + + public error(params: IResponseErrorParams): IResponseErrorResult { + return new ResponseErrorResult({ + webinyTaskId: params.webinyTaskId || this.event.webinyTaskId, + webinyTaskDefinitionId: this.event.webinyTaskDefinitionId, + tenant: params.tenant || this.event.tenant, + locale: params.locale || this.event.locale, + error: transformError(params.error) + }); + } +} diff --git a/packages/tasks/src/response/ResponseAbortedResult.ts b/packages/tasks/src/response/ResponseAbortedResult.ts new file mode 100644 index 00000000000..8897d168094 --- /dev/null +++ b/packages/tasks/src/response/ResponseAbortedResult.ts @@ -0,0 +1,17 @@ +import { TaskResponseStatus } from "~/types"; +import { IResponseAbortedResult } from "./abstractions"; + +export class ResponseAbortedResult implements IResponseAbortedResult { + public readonly webinyTaskId: string; + public readonly webinyTaskDefinitionId: string; + public readonly tenant: string; + public readonly locale: string; + public readonly status: TaskResponseStatus.ABORTED = TaskResponseStatus.ABORTED; + + public constructor(params: Omit) { + this.webinyTaskId = params.webinyTaskId; + this.webinyTaskDefinitionId = params.webinyTaskId; + this.tenant = params.tenant; + this.locale = params.locale; + } +} diff --git a/packages/tasks/src/response/ResponseContinueResult.ts b/packages/tasks/src/response/ResponseContinueResult.ts new file mode 100644 index 00000000000..975a1d804f4 --- /dev/null +++ b/packages/tasks/src/response/ResponseContinueResult.ts @@ -0,0 +1,23 @@ +import { ITaskDataInput, TaskResponseStatus } from "~/types"; +import { IResponseContinueResult } from "./abstractions"; + +export class ResponseContinueResult implements IResponseContinueResult { + public readonly message?: string | undefined; + public readonly webinyTaskId: string; + public readonly webinyTaskDefinitionId: string; + public readonly tenant: string; + public readonly locale: string; + public readonly status: TaskResponseStatus.CONTINUE = TaskResponseStatus.CONTINUE; + public readonly input: T; + public readonly wait?: number; + + public constructor(params: Omit, "status">) { + this.message = params.message; + this.webinyTaskId = params.webinyTaskId; + this.webinyTaskDefinitionId = params.webinyTaskDefinitionId; + this.tenant = params.tenant; + this.locale = params.locale; + this.input = params.input; + this.wait = params.wait; + } +} diff --git a/packages/tasks/src/response/ResponseDoneResult.ts b/packages/tasks/src/response/ResponseDoneResult.ts new file mode 100644 index 00000000000..386c2309086 --- /dev/null +++ b/packages/tasks/src/response/ResponseDoneResult.ts @@ -0,0 +1,19 @@ +import { TaskResponseStatus } from "~/types"; +import { IResponseDoneResult } from "./abstractions"; + +export class ResponseDoneResult implements IResponseDoneResult { + public readonly message?: string | undefined; + public readonly webinyTaskId: string; + public readonly webinyTaskDefinitionId: string; + public readonly tenant: string; + public readonly locale: string; + public readonly status: TaskResponseStatus.DONE = TaskResponseStatus.DONE; + + public constructor(params: Omit) { + this.message = params.message; + this.webinyTaskId = params.webinyTaskId; + this.webinyTaskDefinitionId = params.webinyTaskDefinitionId; + this.tenant = params.tenant; + this.locale = params.locale; + } +} diff --git a/packages/tasks/src/response/ResponseErrorResult.ts b/packages/tasks/src/response/ResponseErrorResult.ts new file mode 100644 index 00000000000..a552aa3189a --- /dev/null +++ b/packages/tasks/src/response/ResponseErrorResult.ts @@ -0,0 +1,19 @@ +import { TaskResponseStatus } from "~/types"; +import { IResponseError, IResponseErrorResult } from "./abstractions"; + +export class ResponseErrorResult implements IResponseErrorResult { + public readonly webinyTaskId: string; + public readonly webinyTaskDefinitionId: string; + public readonly tenant: string; + public readonly locale: string; + public readonly error: IResponseError; + public readonly status: TaskResponseStatus.ERROR = TaskResponseStatus.ERROR; + + public constructor(params: Omit) { + this.webinyTaskId = params.webinyTaskId; + this.webinyTaskDefinitionId = params.webinyTaskDefinitionId; + this.tenant = params.tenant; + this.locale = params.locale; + this.error = params.error; + } +} diff --git a/packages/tasks/src/response/TaskResponse.ts b/packages/tasks/src/response/TaskResponse.ts new file mode 100644 index 00000000000..74ea33108ff --- /dev/null +++ b/packages/tasks/src/response/TaskResponse.ts @@ -0,0 +1,68 @@ +import { + IResponse, + IResponseError, + ITaskResponse, + ITaskResponseAbortedResult, + ITaskResponseContinueOptions, + ITaskResponseContinueResult, + ITaskResponseDoneResult, + ITaskResponseErrorResult +} from "./abstractions"; +import { ITaskDataInput } from "~/types"; + +/** + * There are options to send: + * * seconds - number of seconds to wait + * * date - date until which to wait + */ +const getWaitingTime = (options?: ITaskResponseContinueOptions): number | undefined => { + if (!options) { + return undefined; + } else if ("seconds" in options) { + return options.seconds; + } else if ("date" in options) { + const now = new Date(); + return (options.date.getTime() - now.getTime()) / 1000; + } + return undefined; +}; + +export class TaskResponse implements ITaskResponse { + private readonly response: IResponse; + + public constructor(response: IResponse) { + this.response = response; + } + + public done(message?: string): ITaskResponseDoneResult { + return this.response.done({ + message + }); + } + + public continue( + input: T, + options?: ITaskResponseContinueOptions + ): ITaskResponseContinueResult { + const wait = getWaitingTime(options); + if (!wait || wait < 1) { + return this.response.continue({ + input + }); + } + return this.response.continue({ + input, + wait + }); + } + + public error(error: IResponseError): ITaskResponseErrorResult { + return this.response.error({ + error + }); + } + + public aborted(): ITaskResponseAbortedResult { + return this.response.aborted(); + } +} diff --git a/packages/tasks/src/response/abstractions/Response.ts b/packages/tasks/src/response/abstractions/Response.ts new file mode 100644 index 00000000000..a9e6fcce62d --- /dev/null +++ b/packages/tasks/src/response/abstractions/Response.ts @@ -0,0 +1,37 @@ +import { ITaskEvent } from "~/handler/types"; +import { IResponseContinueParams, IResponseContinueResult } from "./ResponseContinueResult"; +import { IResponseDoneParams, IResponseDoneResult } from "./ResponseDoneResult"; +import { IResponseErrorParams, IResponseErrorResult } from "./ResponseErrorResult"; +import { IResponseAbortedResult } from "./ResponseAbortedResult"; + +export type IResponseFromParams = + | IResponseDoneResult + | IResponseContinueResult + | IResponseErrorResult; + +export type IResponseResult = + | IResponseDoneResult + | IResponseContinueResult + | IResponseErrorResult + | IResponseAbortedResult; + +export interface IResponse { + readonly event: ITaskEvent; + from: (params: IResponseFromParams) => IResponseResult; + done: (params?: IResponseDoneParams) => IResponseDoneResult; + aborted: () => IResponseAbortedResult; + continue: (params: IResponseContinueParams) => IResponseContinueResult; + error: (params: IResponseErrorParams) => IResponseErrorResult; +} + +export interface IResponseAsync { + readonly response: IResponse; + + from: (result: IResponseResult) => Promise; + done: (params: IResponseDoneParams) => Promise; + continue: ( + params: IResponseContinueParams + ) => Promise; + aborted: () => Promise; + error: (params: IResponseErrorParams) => Promise; +} diff --git a/packages/tasks/src/response/abstractions/ResponseAbortedResult.ts b/packages/tasks/src/response/abstractions/ResponseAbortedResult.ts new file mode 100644 index 00000000000..94c27f51069 --- /dev/null +++ b/packages/tasks/src/response/abstractions/ResponseAbortedResult.ts @@ -0,0 +1,6 @@ +import { IResponseBaseResult } from "~/response/abstractions/ResponseBaseResult"; +import { TaskResponseStatus } from "~/types"; + +export interface IResponseAbortedResult extends IResponseBaseResult { + status: TaskResponseStatus.ABORTED; +} diff --git a/packages/tasks/src/response/abstractions/ResponseBaseResult.ts b/packages/tasks/src/response/abstractions/ResponseBaseResult.ts new file mode 100644 index 00000000000..d498d3fa44a --- /dev/null +++ b/packages/tasks/src/response/abstractions/ResponseBaseResult.ts @@ -0,0 +1,9 @@ +import { TaskResponseStatus } from "~/types"; + +export interface IResponseBaseResult { + status: TaskResponseStatus; + webinyTaskId: string; + webinyTaskDefinitionId: string; + tenant: string; + locale: string; +} diff --git a/packages/tasks/src/response/abstractions/ResponseContinueResult.ts b/packages/tasks/src/response/abstractions/ResponseContinueResult.ts new file mode 100644 index 00000000000..5111e897d79 --- /dev/null +++ b/packages/tasks/src/response/abstractions/ResponseContinueResult.ts @@ -0,0 +1,22 @@ +import { ITaskDataInput, TaskResponseStatus } from "~/types"; +import { IResponseBaseResult } from "./ResponseBaseResult"; + +/** + * Wait can be used to pause next iteration of the Lambda execution. + * For example, if the task is hammering the Elasticsearch cluster too much, you can use this to pause the execution for some time. + */ + +export interface IResponseContinueParams { + tenant?: string; + locale?: string; + webinyTaskId?: string; + input: T; + wait?: number; +} + +export interface IResponseContinueResult extends IResponseBaseResult { + message?: string; + input: T; + wait?: number; + status: TaskResponseStatus.CONTINUE; +} diff --git a/packages/tasks/src/response/abstractions/ResponseDoneResult.ts b/packages/tasks/src/response/abstractions/ResponseDoneResult.ts new file mode 100644 index 00000000000..a3708ecb3eb --- /dev/null +++ b/packages/tasks/src/response/abstractions/ResponseDoneResult.ts @@ -0,0 +1,14 @@ +import { TaskResponseStatus } from "~/types"; +import { IResponseBaseResult } from "./ResponseBaseResult"; + +export interface IResponseDoneParams { + tenant?: string; + locale?: string; + webinyTaskId?: string; + message?: string; +} + +export interface IResponseDoneResult extends IResponseBaseResult { + message?: string; + status: TaskResponseStatus.DONE; +} diff --git a/packages/tasks/src/response/abstractions/ResponseErrorResult.ts b/packages/tasks/src/response/abstractions/ResponseErrorResult.ts new file mode 100644 index 00000000000..18b33faa51f --- /dev/null +++ b/packages/tasks/src/response/abstractions/ResponseErrorResult.ts @@ -0,0 +1,21 @@ +import { TaskResponseStatus } from "~/types"; +import { IResponseBaseResult } from "./ResponseBaseResult"; + +export interface IResponseError { + message: string; + code: string; + data?: Record; + stack?: string; +} + +export interface IResponseErrorParams { + error: IResponseError; + tenant?: string; + locale?: string; + webinyTaskId?: string; +} + +export interface IResponseErrorResult extends IResponseBaseResult { + error: IResponseError; + status: TaskResponseStatus.ERROR; +} diff --git a/packages/tasks/src/response/abstractions/TaskResponse.ts b/packages/tasks/src/response/abstractions/TaskResponse.ts new file mode 100644 index 00000000000..939d9e1b94f --- /dev/null +++ b/packages/tasks/src/response/abstractions/TaskResponse.ts @@ -0,0 +1,46 @@ +import { ITaskDataInput, TaskResponseStatus } from "~/types"; +import { IResponseError } from "./ResponseErrorResult"; + +export type ITaskResponseResult = + | ITaskResponseDoneResult + | ITaskResponseContinueResult + | ITaskResponseErrorResult + | ITaskResponseAbortedResult; + +export interface ITaskResponseDoneResult { + message?: string; + status: TaskResponseStatus.DONE; +} + +export interface ITaskResponseContinueResult { + input: T; + wait?: number; + status: TaskResponseStatus.CONTINUE; +} + +export interface ITaskResponseErrorResult { + error: IResponseError; + status: TaskResponseStatus.ERROR; +} + +export interface ITaskResponseAbortedResult { + status: TaskResponseStatus.ABORTED; +} + +export interface ITaskResponseContinueOptionsUntil { + date: Date; +} +export interface ITaskResponseContinueOptionsSeconds { + seconds: number; +} + +export type ITaskResponseContinueOptions = + | ITaskResponseContinueOptionsUntil + | ITaskResponseContinueOptionsSeconds; + +export interface ITaskResponse { + done: (message?: string) => ITaskResponseDoneResult; + continue: (data: T, options?: ITaskResponseContinueOptions) => ITaskResponseContinueResult; + error: (error: IResponseError) => ITaskResponseErrorResult; + aborted: () => ITaskResponseAbortedResult; +} diff --git a/packages/tasks/src/response/abstractions/index.ts b/packages/tasks/src/response/abstractions/index.ts new file mode 100644 index 00000000000..dc3d8e61093 --- /dev/null +++ b/packages/tasks/src/response/abstractions/index.ts @@ -0,0 +1,7 @@ +export * from "./Response"; +export * from "./ResponseContinueResult"; +export * from "./ResponseDoneResult"; +export * from "./ResponseErrorResult"; +export * from "./ResponseAbortedResult"; +export * from "./ResponseBaseResult"; +export * from "./TaskResponse"; diff --git a/packages/tasks/src/response/index.ts b/packages/tasks/src/response/index.ts new file mode 100644 index 00000000000..10919b3d8d4 --- /dev/null +++ b/packages/tasks/src/response/index.ts @@ -0,0 +1,7 @@ +export * from "./DatabaseResponse"; +export * from "./Response"; +export * from "./ResponseContinueResult"; +export * from "./ResponseDoneResult"; +export * from "./ResponseAbortedResult"; +export * from "./ResponseErrorResult"; +export * from "./TaskResponse"; diff --git a/packages/tasks/src/runner/TaskControl.ts b/packages/tasks/src/runner/TaskControl.ts new file mode 100644 index 00000000000..eb3dd907b67 --- /dev/null +++ b/packages/tasks/src/runner/TaskControl.ts @@ -0,0 +1,157 @@ +import { ITaskEvent } from "~/handler/types"; +import { Context, ITaskData, ITaskDataInput, ITaskLog, TaskDataStatus } from "~/types"; +import { ITaskControl, ITaskRunner } from "./abstractions"; +import { TaskManager } from "./TaskManager"; +import { IResponse, IResponseErrorResult, IResponseResult } from "~/response/abstractions"; +import { DatabaseResponse, TaskResponse } from "~/response"; +import { TaskManagerStore } from "./TaskManagerStore"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { getObjectProperties } from "~/runner/utils/getObjectProperties"; + +export class TaskControl implements ITaskControl { + public readonly runner: ITaskRunner; + public readonly response: IResponse; + public readonly context: Context; + + public constructor(runner: ITaskRunner, response: IResponse, context: Context) { + this.runner = runner; + this.context = context; + this.response = response; + } + + public async run(event: Pick): Promise { + const taskId = event.webinyTaskId; + /** + * This is the initial getTask idea. + * We will need to take care of child tasks: + * * child tasks can be in multiple levels (child task creates a child task, etc...). + * * child tasks could be executed in parallel. + */ + let task: ITaskData; + try { + task = await this.getTask(taskId); + } catch (ex) { + return getObjectProperties(ex); + } + /** + * As this as a run of the task, we need to create a new log entry. + */ + + let taskLog: ITaskLog; + try { + taskLog = await this.getTaskLog(task); + } catch (ex) { + return getObjectProperties(ex); + } + /** + * Make sure that task does not run if it is aborted. + * This will effectively end the Step Function execution with a "success" status. + */ + if (task.taskStatus === TaskDataStatus.ABORTED) { + return this.response.aborted(); + } + + const taskResponse = new TaskResponse(this.response); + const store = new TaskManagerStore(this.context, task, taskLog); + + const manager = new TaskManager( + this.runner, + this.context, + this.response, + taskResponse, + store + ); + + const databaseResponse = new DatabaseResponse(this.response, store); + + const definition = this.context.tasks.getDefinition(task.definitionId); + if (!definition) { + return await databaseResponse.error({ + error: { + message: `Task "${task.id}" cannot be executed because there is no "${task.definitionId}" definition plugin.`, + code: "TASK_DEFINITION_ERROR", + data: { + definitionId: task.definitionId + } + } + }); + } + + try { + const result = await manager.run(definition); + + return await databaseResponse.from(result); + } catch (ex) { + return this.response.error({ + error: { + message: ex.message, + code: ex.code || "TASK_ERROR", + stack: ex.stack, + data: { + ...ex.data, + input: task.input + } + } + }); + } + } + + private async getTask(id: string): Promise> { + try { + const task = await this.runner.context.tasks.getTask(id); + if (task) { + return task; + } + } catch (ex) { + throw this.response.error({ + error: { + message: ex.message, + code: ex.code || "TASK_ERROR", + stack: ex.stack, + data: ex.data + } + }); + } + throw this.response.error({ + error: { + message: `Task "${id}" cannot be executed because it does not exist.`, + code: "TASK_NOT_FOUND" + } + }); + } + + private async getTaskLog(task: ITaskData): Promise { + let taskLog: ITaskLog | null = null; + /** + * First we are trying to get existing latest log. + */ + try { + taskLog = await this.context.tasks.getLatestLog(task.id); + } catch (ex) { + /** + * If error is not the NotFoundError, we need to throw it. + */ + if (ex instanceof NotFoundError === false) { + throw this.response.error({ + error: getObjectProperties(ex) + }); + } + /** + * Otherwise just continue and create a new log. + */ + } + + const currentIteration = taskLog?.iteration || 0; + + try { + return await this.context.tasks.createLog(task, { + executionName: this.response.event.executionName, + iteration: currentIteration + 1 + }); + } catch (ex) { + throw this.response.error({ + error: getObjectProperties(ex) + }); + } + } +} diff --git a/packages/tasks/src/runner/TaskEventValidation.ts b/packages/tasks/src/runner/TaskEventValidation.ts new file mode 100644 index 00000000000..8a6f231faaf --- /dev/null +++ b/packages/tasks/src/runner/TaskEventValidation.ts @@ -0,0 +1,26 @@ +import zod from "zod"; +import { createZodError } from "@webiny/utils"; +import { ITaskEventValidation, ITaskEventValidationResult } from "./abstractions"; +import { ITaskEvent } from "~/handler/types"; + +const validation = zod + .object({ + webinyTaskId: zod.string(), + webinyTaskDefinitionId: zod.string(), + endpoint: zod.string(), + tenant: zod.string(), + locale: zod.string(), + executionName: zod.string(), + stateMachineId: zod.string() + }) + .required(); + +export class TaskEventValidation implements ITaskEventValidation { + public validate(event: Partial): ITaskEventValidationResult { + const result = validation.safeParse(event); + if (result.success) { + return result.data; + } + throw createZodError(result.error); + } +} diff --git a/packages/tasks/src/runner/TaskManager.ts b/packages/tasks/src/runner/TaskManager.ts new file mode 100644 index 00000000000..3997b45ff3e --- /dev/null +++ b/packages/tasks/src/runner/TaskManager.ts @@ -0,0 +1,123 @@ +import { ITaskManager, ITaskRunner } from "./abstractions"; +import { + Context, + ITaskDataInput, + ITaskDefinition, + TaskDataStatus, + TaskResponseStatus +} from "~/types"; +import { + IResponse, + IResponseResult, + ITaskResponse, + ITaskResponseResult +} from "~/response/abstractions"; +import { ITaskManagerStore } from "~/runner/abstractions"; +import { getObjectProperties } from "~/runner/utils/getObjectProperties"; + +export class TaskManager implements ITaskManager { + private readonly runner: Pick; + private readonly context: Context; + private readonly response: IResponse; + private readonly taskResponse: ITaskResponse; + private readonly store: ITaskManagerStore; + + public constructor( + runner: Pick, + context: Context, + response: IResponse, + taskResponse: ITaskResponse, + store: ITaskManagerStore + ) { + this.runner = runner; + this.context = context; + this.response = response; + this.taskResponse = taskResponse; + this.store = store; + } + + public async run(definition: ITaskDefinition): Promise { + /** + * If task was aborted, do not run it again, return as it was done. + */ + if (this.store.getStatus() === TaskDataStatus.ABORTED) { + return this.response.aborted(); + } + /** + * If the task status is pending, update it to running and add a log. + */ + // + else if (this.store.getStatus() === TaskDataStatus.PENDING) { + try { + await this.store.updateTask({ + taskStatus: TaskDataStatus.RUNNING, + startedOn: new Date().toISOString(), + executionName: this.response.event.executionName, + iterations: 1 + }); + await this.store.addInfoLog({ + message: "Task started." + }); + } catch (ex) { + return this.response.error({ + error: getObjectProperties(ex) + }); + } + } + /** + * Always update the task iteration. + */ + // + else { + try { + await this.store.updateTask(task => { + return { + iterations: task.iterations + 1 + }; + }); + } catch (ex) { + return this.response.error({ + error: getObjectProperties(ex) + }); + } + } + + let result: ITaskResponseResult; + + try { + const input = structuredClone(this.store.getInput()); + result = await definition.run({ + input, + context: this.context, + response: this.taskResponse, + isCloseToTimeout: () => { + return this.runner.isCloseToTimeout(); + }, + isAborted: () => { + return this.store.getStatus() === TaskDataStatus.ABORTED; + }, + store: this.store + }); + } catch (ex) { + return this.response.error({ + error: getObjectProperties(ex) + }); + } + + if (result.status === TaskResponseStatus.CONTINUE) { + return this.response.continue({ + input: result.input, + wait: result.wait + }); + } else if (result.status === TaskResponseStatus.ERROR) { + return this.response.error({ + error: result.error + }); + } else if (result.status === TaskResponseStatus.ABORTED) { + return this.response.aborted(); + } + return this.response.done({ + message: result.message + }); + } +} diff --git a/packages/tasks/src/runner/TaskManagerStore.ts b/packages/tasks/src/runner/TaskManagerStore.ts new file mode 100644 index 00000000000..5cc459cebef --- /dev/null +++ b/packages/tasks/src/runner/TaskManagerStore.ts @@ -0,0 +1,122 @@ +import { + ITaskData, + ITaskDataInput, + ITaskLog, + ITaskLogItemType, + ITaskManagerStoreInfoLog, + ITasksContextObject, + TaskDataStatus +} from "~/types"; +import { + ITaskManagerStore, + ITaskManagerStoreErrorLog, + ITaskManagerStoreUpdateTaskInputParam, + ITaskManagerStoreUpdateTaskParam +} from "./abstractions"; +/** + * Package deep-equal does not have types. + */ +// @ts-expect-error +import deepEqual from "deep-equal"; +import { getObjectProperties } from "~/runner/utils/getObjectProperties"; + +const getInput = ( + originalInput: T, + input: ITaskManagerStoreUpdateTaskInputParam +) => { + if (typeof input === "function") { + return input(originalInput); + } + return { + ...originalInput, + ...input + }; +}; + +export interface TaskManagerStoreContext { + tasks: Pick; +} + +export class TaskManagerStore implements ITaskManagerStore { + private readonly context: TaskManagerStoreContext; + private task: ITaskData; + private taskLog: ITaskLog; + + public constructor(context: TaskManagerStoreContext, task: ITaskData, log: ITaskLog) { + this.context = context; + this.task = task; + this.taskLog = log; + } + + public getStatus(): TaskDataStatus { + return this.task.taskStatus; + } + + public setTask(task: ITaskData): void { + this.task = task; + } + + public getTask(): ITaskData { + return this.task as ITaskData; + } + + public async updateTask(param: ITaskManagerStoreUpdateTaskParam): Promise { + const data = typeof param === "function" ? param(this.task) : param; + /** + * No need to update if nothing changed. + */ + if (deepEqual(data, this.task)) { + return; + } + this.task = await this.context.tasks.updateTask(this.task.id, { + ...this.task, + ...data + }); + } + + public async updateInput( + param: ITaskManagerStoreUpdateTaskInputParam + ): Promise { + const input = getInput(this.task.input, param); + + /** + * No need to update if nothing changed. + */ + if (deepEqual(input, this.task.input)) { + return; + } + this.task = await this.context.tasks.updateTask(this.task.id, { + input + }); + } + + public getInput(): T { + return this.task.input as T; + } + + public async addInfoLog(log: ITaskManagerStoreInfoLog): Promise { + this.taskLog = await this.context.tasks.updateLog(this.taskLog.id, { + items: this.taskLog.items.concat([ + { + message: log.message, + data: log.data, + type: ITaskLogItemType.INFO, + createdOn: new Date().toISOString() + } + ]) + }); + } + + public async addErrorLog(log: ITaskManagerStoreErrorLog): Promise { + this.taskLog = await this.context.tasks.updateLog(this.taskLog.id, { + items: this.taskLog.items.concat([ + { + message: log.message, + error: getObjectProperties(log.error), + type: ITaskLogItemType.ERROR, + createdOn: new Date().toISOString() + } + ]) + }); + } +} diff --git a/packages/tasks/src/runner/TaskRunner.ts b/packages/tasks/src/runner/TaskRunner.ts new file mode 100644 index 00000000000..a15c53d8373 --- /dev/null +++ b/packages/tasks/src/runner/TaskRunner.ts @@ -0,0 +1,88 @@ +import { Context as LambdaContext } from "aws-lambda/handler"; +import { Reply, Request } from "@webiny/handler/types"; +import { ITaskEvent } from "~/handler/types"; +import { ITaskRunner } from "./abstractions"; +import { Context } from "~/types"; +import { Response } from "~/response"; +import { TaskControl } from "./TaskControl"; +import { TaskEventValidation } from "./TaskEventValidation"; +import { IResponseResult } from "~/response/abstractions"; +import { getObjectProperties } from "~/runner/utils/getObjectProperties"; + +const transformMinutesIntoMilliseconds = (minutes: number) => { + return minutes * 60000; +}; + +const DEFAULT_TASKS_TIMEOUT_CLOSE_MINUTES = 3; + +export class TaskRunner implements ITaskRunner { + /** + * When DI is introduced, these will get injected. + * + * container.bind("Request").toConstantValue(request); + * @inject("Request") public readonly request: Request; + * + * Follow the same example for the rest of the properties. + */ + public readonly request: Request; + public readonly reply: Reply; + public readonly context: C; + public readonly lambdaContext: LambdaContext; + private readonly validation: TaskEventValidation; + + /** + * We take all required variables separately because they will get injected via DI - so less refactoring is required in the future. + */ + public constructor( + lambdaContext: LambdaContext, + request: Request, + reply: Reply, + context: C, + validation: TaskEventValidation = new TaskEventValidation() + ) { + this.request = request; + this.reply = reply; + this.context = context; + this.lambdaContext = lambdaContext; + this.validation = validation; + } + + public isCloseToTimeout() { + return ( + this.lambdaContext.getRemainingTimeInMillis() < + transformMinutesIntoMilliseconds(this.getIsCloseToTimeoutMinutes()) + ); + } + + public getRemainingTime() { + return this.lambdaContext.getRemainingTimeInMillis(); + } + + public async run(input: ITaskEvent): Promise { + const response = new Response(input); + + let event: ITaskEvent; + try { + event = this.validation.validate(input); + } catch (ex) { + return response.error({ + error: getObjectProperties(ex) + }); + } + + const control = new TaskControl(this, response, this.context); + + try { + return await control.run(event); + } catch (ex) { + return response.error({ + error: getObjectProperties(ex) + }); + } + } + + private getIsCloseToTimeoutMinutes() { + const value = parseInt(process.env["WEBINY_TASKS_TIMEOUT_CLOSE_MINUTES"] || ""); + return value > 0 ? value : DEFAULT_TASKS_TIMEOUT_CLOSE_MINUTES; + } +} diff --git a/packages/tasks/src/runner/abstractions/TaskControl.ts b/packages/tasks/src/runner/abstractions/TaskControl.ts new file mode 100644 index 00000000000..2a8e301df99 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/TaskControl.ts @@ -0,0 +1,12 @@ +import { ITaskRunner } from "~/runner/abstractions"; +import { IResponse, IResponseResult } from "~/response/abstractions"; +import { Context } from "~/types"; +import { ITaskEvent } from "~/handler/types"; + +export interface ITaskControl { + runner: ITaskRunner; + response: IResponse; + context: Context; + + run(event: ITaskEvent): Promise; +} diff --git a/packages/tasks/src/runner/abstractions/TaskEventValidation.ts b/packages/tasks/src/runner/abstractions/TaskEventValidation.ts new file mode 100644 index 00000000000..8ba000efbd0 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/TaskEventValidation.ts @@ -0,0 +1,7 @@ +import { ITaskEvent } from "~/handler/types"; + +export type ITaskEventValidationResult = ITaskEvent; + +export interface ITaskEventValidation { + validate: (event: Partial) => ITaskEventValidationResult; +} diff --git a/packages/tasks/src/runner/abstractions/TaskManager.ts b/packages/tasks/src/runner/abstractions/TaskManager.ts new file mode 100644 index 00000000000..32017c683d9 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/TaskManager.ts @@ -0,0 +1,6 @@ +import { IResponseResult } from "~/response/abstractions"; +import { ITaskData, ITaskDataInput, ITaskDefinition } from "~/types"; + +export interface ITaskManager { + run: (definition: ITaskDefinition, task: ITaskData) => Promise; +} diff --git a/packages/tasks/src/runner/abstractions/TaskManagerStore.ts b/packages/tasks/src/runner/abstractions/TaskManagerStore.ts new file mode 100644 index 00000000000..f658300a699 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/TaskManagerStore.ts @@ -0,0 +1,60 @@ +import { + IResponseError, + ITaskData, + ITaskDataInput, + ITaskLogItemData, + ITaskUpdateData, + TaskDataStatus +} from "~/types"; + +export type ITaskManagerStoreUpdateTaskValues = T; + +export interface ITaskManagerStoreUpdateTaskValuesCb { + (input: T): Partial; +} + +export type ITaskManagerStoreUpdateTaskInputParam = + | ITaskManagerStoreUpdateTaskValuesCb + | Partial>; + +export interface ITaskManagerStoreUpdateTaskParamCb { + (task: ITaskData): ITaskUpdateData; +} + +export type ITaskManagerStoreUpdateTask = + ITaskUpdateData; + +export type ITaskManagerStoreUpdateTaskParam = + | ITaskManagerStoreUpdateTaskParamCb + | Partial>; + +export interface ITaskManagerStoreInfoLog { + message: string; + data?: ITaskLogItemData; +} + +export interface ITaskManagerStoreErrorLog { + message: string; + data?: ITaskLogItemData; + error: IResponseError; +} + +export interface ITaskManagerStore { + setTask: (task: ITaskData) => void; + getTask: () => ITaskData; + getStatus: () => TaskDataStatus; + /** + * @throws {Error} If task not found or something goes wrong during the database update. + */ + updateTask: (params: ITaskManagerStoreUpdateTaskParam) => Promise; + /** + * Update task input, which are used to store custom user data. + * You can send partial input, and they will be merged with the existing input. + * + * @throws {Error} If task not found or something goes wrong during the database update. + */ + updateInput: (params: ITaskManagerStoreUpdateTaskInputParam) => Promise; + getInput: () => T; + addInfoLog: (log: ITaskManagerStoreInfoLog) => Promise; + addErrorLog: (log: ITaskManagerStoreErrorLog) => Promise; +} diff --git a/packages/tasks/src/runner/abstractions/TaskRunner.ts b/packages/tasks/src/runner/abstractions/TaskRunner.ts new file mode 100644 index 00000000000..92069749200 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/TaskRunner.ts @@ -0,0 +1,16 @@ +import { Context as LambdaContext } from "aws-lambda/handler"; +import { Context } from "~/types"; +import { Reply, Request } from "@webiny/handler/types"; +import { ITaskEvent } from "~/handler/types"; +import { IResponseResult } from "~/response/abstractions"; + +export interface ITaskRunner { + request: Request; + reply: Reply; + context: C; + lambdaContext: LambdaContext; + isCloseToTimeout: () => boolean; + getRemainingTime: () => number; + + run(event: ITaskEvent): Promise; +} diff --git a/packages/tasks/src/runner/abstractions/index.ts b/packages/tasks/src/runner/abstractions/index.ts new file mode 100644 index 00000000000..5fcafcc98e9 --- /dev/null +++ b/packages/tasks/src/runner/abstractions/index.ts @@ -0,0 +1,5 @@ +export * from "./TaskControl"; +export * from "./TaskEventValidation"; +export * from "./TaskManager"; +export * from "./TaskRunner"; +export * from "./TaskManagerStore"; diff --git a/packages/tasks/src/runner/index.ts b/packages/tasks/src/runner/index.ts new file mode 100644 index 00000000000..dfc33de5bb1 --- /dev/null +++ b/packages/tasks/src/runner/index.ts @@ -0,0 +1 @@ +export * from "./TaskRunner"; diff --git a/packages/tasks/src/runner/utils/getErrorProperties.ts b/packages/tasks/src/runner/utils/getErrorProperties.ts new file mode 100644 index 00000000000..234e94dff06 --- /dev/null +++ b/packages/tasks/src/runner/utils/getErrorProperties.ts @@ -0,0 +1,11 @@ +import { IResponseError } from "~/response/abstractions"; + +/** + * Unfortunately we need some casting as we do not know which properties are available on the error object. + */ +export const getErrorProperties = (error: Error): IResponseError => { + return Object.getOwnPropertyNames(error).reduce>((acc, key) => { + acc[key] = error[key as keyof Error]; + return acc; + }, {}) as unknown as IResponseError; +}; diff --git a/packages/tasks/src/runner/utils/getObjectProperties.ts b/packages/tasks/src/runner/utils/getObjectProperties.ts new file mode 100644 index 00000000000..a4e685bd8d4 --- /dev/null +++ b/packages/tasks/src/runner/utils/getObjectProperties.ts @@ -0,0 +1,12 @@ +/** + * Unfortunately we need some casting as we do not know which properties are available on the object. + */ +export const getObjectProperties = >(input: unknown): T => { + if (!input || typeof input !== "object") { + return {} as unknown as T; + } + return Object.getOwnPropertyNames(input).reduce>((acc, key) => { + acc[key] = (input as Record)[key]; + return acc; + }, {}) as unknown as T; +}; diff --git a/packages/tasks/src/task/index.ts b/packages/tasks/src/task/index.ts new file mode 100644 index 00000000000..f63c5bae96f --- /dev/null +++ b/packages/tasks/src/task/index.ts @@ -0,0 +1,2 @@ +export * from "./input"; +export * from "./plugin"; diff --git a/packages/tasks/src/task/input.ts b/packages/tasks/src/task/input.ts new file mode 100644 index 00000000000..c7ceb562e88 --- /dev/null +++ b/packages/tasks/src/task/input.ts @@ -0,0 +1,27 @@ +export interface ITaskInputParams { + id: string; + input: T; +} +class TaskInput { + public id: string; + public input: T; + + constructor(params: ITaskInputParams) { + this.id = params.id; + this.input = params.input; + } +} + +export type { TaskInput }; + +interface ICreateTaskInputParams { + id: string; + input: T; +} + +export const createTaskInput = (params: ICreateTaskInputParams) => { + return new TaskInput({ + id: params.id, + input: params.input + }); +}; diff --git a/packages/tasks/src/task/plugin.ts b/packages/tasks/src/task/plugin.ts new file mode 100644 index 00000000000..ae5bb55af29 --- /dev/null +++ b/packages/tasks/src/task/plugin.ts @@ -0,0 +1,94 @@ +import camelCase from "lodash/camelCase"; +import WebinyError from "@webiny/error"; +import { Plugin } from "@webiny/plugins"; +import { Context, ITaskDefinition, ITaskDefinitionField } from "~/types"; + +export interface ITaskPluginSetFieldsCallback { + (fields: ITaskDefinitionField[]): ITaskDefinitionField[] | undefined; +} + +export interface ITaskDefinitionParams + extends Omit, "fields"> { + config?: (task: Pick, "addField" | "setFields">) => void; +} + +export class TaskDefinitionPlugin + extends Plugin + implements ITaskDefinition +{ + public static override readonly type: string = "webiny.backgroundTask"; + + private readonly task: ITaskDefinition; + + public get id() { + return this.task.id; + } + + public get title() { + return this.task.title; + } + + public get fields() { + return this.task.fields; + } + + public get run() { + return this.task.run; + } + + public get onDone() { + return this.task.onDone; + } + + public get onError() { + return this.task.onError; + } + + public constructor(task: ITaskDefinitionParams) { + super(); + this.task = { + ...task, + fields: [] + }; + if (typeof task.config === "function") { + task.config(this); + } + this.validate(); + } + + public getTask() { + return this.task; + } + + public setFields(cb: ITaskPluginSetFieldsCallback) { + const fields = Array.from(this.task.fields || []); + this.task.fields = cb(fields); + } + + public addField(field: ITaskDefinitionField) { + this.task.fields = (this.task.fields || []).concat([field]); + } + /** + * TODO implement zod validation if validation becomes too complex + */ + private validate(): void { + if (camelCase(this.task.id) !== this.task.id) { + /** + * We want to log and throw the message so it can be seen in the CloudWatch logs. + */ + const message = `Task ID "${this.task.id}" is invalid. It must be in camelCase format, for example: "myCustomTask".`; + console.log(message); + throw new WebinyError(message); + } + } +} + +export const createTaskDefinition = ( + params: ITaskDefinitionParams +) => { + return new TaskDefinitionPlugin(params); +}; + +export const createTaskDefinitionField = (params: ITaskDefinitionField) => { + return params; +}; diff --git a/packages/tasks/src/types.ts b/packages/tasks/src/types.ts new file mode 100644 index 00000000000..9ec14d928ba --- /dev/null +++ b/packages/tasks/src/types.ts @@ -0,0 +1,327 @@ +import { + CmsContext as BaseContext, + CmsEntryListParams, + CmsEntryMeta, + CmsModel, + CmsModelField +} from "@webiny/api-headless-cms/types"; +import { Topic } from "@webiny/pubsub/types"; +import { IResponseError, ITaskResponse, ITaskResponseResult } from "~/response/abstractions"; +import { ITaskManagerStore } from "./runner/abstractions"; +import { EventBridgeClientSendResponse } from "@webiny/aws-sdk/client-eventbridge"; +import { SecurityPermission } from "@webiny/api-security/types"; + +export * from "./response/abstractions"; +export * from "./runner/abstractions"; + +export interface ITaskConfig { + readonly eventBusName: string; +} + +export interface ITaskDataInput { + [key: string]: any; +} + +export enum ITaskLogItemType { + INFO = "info", + ERROR = "error" +} + +export interface ITaskLogItemData { + [key: string]: any; +} + +export interface ITaskLogItemBase { + message: string; + createdOn: string; + type: ITaskLogItemType; + data?: ITaskLogItemData; +} + +export interface ITaskLogItemInfo extends ITaskLogItemBase { + type: ITaskLogItemType.INFO; +} + +export interface ITaskLogItemError extends ITaskLogItemBase { + type: ITaskLogItemType.ERROR; + error?: IResponseError; +} + +export type ITaskLogItem = ITaskLogItemInfo | ITaskLogItemError; + +export interface ITaskLog { + /** + * ID without the revision number (for example: #0001). + */ + id: string; + createdOn: string; + createdBy: ITaskIdentity; + executionName: string; + task: string; + iteration: number; + items: ITaskLogItem[]; +} + +export enum TaskDataStatus { + PENDING = "pending", + RUNNING = "running", + FAILED = "failed", + SUCCESS = "success", + ABORTED = "aborted" +} + +export interface ITaskIdentity { + id: string; + displayName: string | null; + type: string; +} + +export interface ITaskData { + /** + * ID without the revision number (for example: #0001). + */ + id: string; + name: string; + taskStatus: TaskDataStatus; + definitionId: string; + executionName: string; + input: T; + createdOn: string; + savedOn: string; + createdBy: ITaskIdentity; + startedOn?: string; + finishedOn?: string; + eventResponse: EventBridgeClientSendResponse | undefined; + iterations: number; +} + +export type IGetTaskResponse = ITaskData | null; + +export interface IListTasksResponse { + items: ITaskData[]; + meta: CmsEntryMeta; +} + +export interface IListTaskLogsResponse { + items: ITaskLog[]; + meta: CmsEntryMeta; +} + +export type ICreateTaskResponse = ITaskData; +export type IUpdateTaskResponse = ITaskData; +export type IDeleteTaskResponse = boolean; + +export type IListTaskParams = Omit; +export type IListTaskLogParams = Omit; + +export interface ITaskCreateData { + definitionId: string; + name: string; + input: T; +} + +export interface ITaskUpdateData { + name?: string; + input?: T; + taskStatus?: TaskDataStatus; + executionName?: string; + startedOn?: string; + finishedOn?: string; + eventResponse?: Record; + iterations?: number; +} + +export interface OnTaskBeforeCreateTopicParams { + input: ITaskCreateData; +} + +export interface OnTaskAfterCreateTopicParams { + input: ITaskCreateData; + task: ITaskData; +} + +export interface OnTaskBeforeUpdateTopicParams { + input: ITaskUpdateData; + original: ITaskData; +} + +export interface OnTaskAfterUpdateTopicParams { + input: ITaskUpdateData; + task: ITaskData; +} + +export interface OnTaskBeforeDeleteTopicParams { + task: ITaskData; +} + +export interface OnTaskAfterDeleteTopicParams { + task: ITaskData; +} + +export interface ITaskLogCreateInput { + executionName: string; + iteration: number; +} + +export interface ITaskLogUpdateInput { + items: ITaskLogItem[]; +} + +export interface ITasksContextCrudObject { + /** + * Models + */ + getTaskModel: () => Promise; + getLogModel: () => Promise; + /** + * Tasks + */ + getTask: (id: string) => Promise | null>; + listTasks: (params?: IListTaskParams) => Promise>; + createTask: (task: ITaskCreateData) => Promise>; + updateTask: ( + id: string, + data: Partial> + ) => Promise>; + deleteTask: (id: string) => Promise; + /** + * Logs + */ + createLog: (task: Pick, data: ITaskLogCreateInput) => Promise; + updateLog: (id: string, data: ITaskLogUpdateInput) => Promise; + getLog: (id: string) => Promise; + getLatestLog: (taskId: string) => Promise; + listLogs: (params: IListTaskLogParams) => Promise; + /** + * Lifecycle events. + */ + onTaskBeforeCreate: Topic; + onTaskAfterCreate: Topic; + onTaskBeforeUpdate: Topic; + onTaskAfterUpdate: Topic; + onTaskBeforeDelete: Topic; + onTaskAfterDelete: Topic; +} + +export interface ITasksContextConfigObject { + config: ITaskConfig; +} + +export interface ITasksContextDefinitionObject { + getDefinition: (id: string) => ITaskDefinition | null; + listDefinitions: () => ITaskDefinition[]; +} + +export interface ITaskTriggerParams { + definition: string; + name?: string; + input?: T; +} + +export interface ITaskAbortParams { + id: string; + message?: string; +} + +export interface ITasksContextTriggerObject { + trigger: (params: ITaskTriggerParams) => Promise>; + abort: (params: ITaskAbortParams) => Promise>; +} + +export interface ITasksContextObject + extends ITasksContextCrudObject, + ITasksContextDefinitionObject, + ITasksContextTriggerObject, + ITasksContextConfigObject {} + +export interface Context extends BaseContext { + tasks: ITasksContextObject; +} + +export interface ITaskRunParams { + context: C; + response: ITaskResponse; + isCloseToTimeout: () => boolean; + isAborted: () => boolean; + input: I; + store: ITaskManagerStore; +} + +export interface ITaskSuccessParams { + context: C; + input: I; +} + +export interface ITaskErrorParams { + context: C; + input: I; +} + +export enum TaskResponseStatus { + DONE = "done", + ERROR = "error", + CONTINUE = "continue", + ABORTED = "aborted" +} + +export type ITaskDefinitionField = Pick< + CmsModelField, + | "fieldId" + | "type" + | "label" + | "renderer" + | "helpText" + | "placeholderText" + | "predefinedValues" + | "validation" + | "listValidation" + | "multipleValues" + | "settings" +>; + +export interface ITaskBeforeTriggerParams { + context: C; + input: I; +} + +export interface ITaskDefinition { + /** + * ID of the task must be unique in the system. + * It should be in camelCase format, for example: "myCustomTask". + */ + id: string; + /** + * Name should be unique, as it will get used to identify the task in the UI. + */ + title: string; + /** + * A description of the task, for the UI. + */ + description?: string; + /** + * Task run method. + */ + run: (params: ITaskRunParams) => Promise; + /** + * When a new task is about to be triggered, we will run this method. + * For example, you can use this method to check if there is a task of the same type already running. + */ + onBeforeTrigger?: (params: ITaskBeforeTriggerParams) => Promise; + /** + * When task successfully finishes, this method will be called. + */ + onDone?: (params: ITaskSuccessParams) => Promise; + /** + * When task fails, this method will be called. + */ + onError?: (params: ITaskErrorParams) => Promise; + /** + * Custom input fields and layout for the task input. + */ + fields?: ITaskDefinitionField[]; +} + +export interface TaskPermission extends SecurityPermission { + name: "task"; + rwd?: string; +} diff --git a/packages/tasks/tsconfig.build.json b/packages/tasks/tsconfig.build.json new file mode 100644 index 00000000000..c0a14e59d80 --- /dev/null +++ b/packages/tasks/tsconfig.build.json @@ -0,0 +1,27 @@ +{ + "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": "../error/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, + { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-security/tsconfig.build.json" }, + { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../api-wcp/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/tasks/tsconfig.json b/packages/tasks/tsconfig.json new file mode 100644 index 00000000000..18bc6e09ee4 --- /dev/null +++ b/packages/tasks/tsconfig.json @@ -0,0 +1,58 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api" }, + { "path": "../api-headless-cms" }, + { "path": "../aws-sdk" }, + { "path": "../error" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../handler-graphql" }, + { "path": "../plugins" }, + { "path": "../pubsub" }, + { "path": "../utils" }, + { "path": "../api-i18n" }, + { "path": "../api-security" }, + { "path": "../api-tenancy" }, + { "path": "../api-wcp" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "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/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-security/*": ["../api-security/src/*"], + "@webiny/api-security": ["../api-security/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/api-wcp/*": ["../api-wcp/src/*"], + "@webiny/api-wcp": ["../api-wcp/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/tasks/webiny.config.js b/packages/tasks/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/tasks/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/utils/src/createZodError.ts b/packages/utils/src/createZodError.ts index 609a7c025c4..d5d1dca1e50 100644 --- a/packages/utils/src/createZodError.ts +++ b/packages/utils/src/createZodError.ts @@ -7,7 +7,7 @@ interface OutputError { message: string; } -interface OutputErrors { +export interface OutputErrors { [key: string]: OutputError; } diff --git a/packages/utils/src/headers.ts b/packages/utils/src/headers.ts index 79de4f8abc2..422464b43f3 100644 --- a/packages/utils/src/headers.ts +++ b/packages/utils/src/headers.ts @@ -1,17 +1,15 @@ export const WEBINY_VERSION_HEADER = "x-webiny-version"; -export interface Headers { - [WEBINY_VERSION_HEADER]: string; -} -export const getWebinyVersionHeaders = (): Headers => { + +export const getWebinyVersionHeaders = () => { const enable: string | undefined = process.env.WEBINY_ENABLE_VERSION_HEADER; const version: string | undefined = process.env.WEBINY_VERSION; /** * Disable version headers by default. */ if (enable !== "true" || !version) { - return {} as Headers; + return {}; } return { - [WEBINY_VERSION_HEADER]: version + "x-webiny-version": version }; }; diff --git a/scripts/listPackagesWithTests.js b/scripts/listPackagesWithTests.js index 73fb442f020..4f545d38bf8 100644 --- a/scripts/listPackagesWithTests.js +++ b/scripts/listPackagesWithTests.js @@ -114,6 +114,12 @@ const CUSTOM_HANDLERS = { }, "app-file-manager": () => { return ["packages/app-file-manager"]; + }, + tasks: () => { + return ["packages/tasks --storage=ddb", "packages/tasks --storage=ddb-es,ddb"]; + }, + "api-elasticsearch-tasks": () => { + return ["packages/api-elasticsearch-tasks --storage=ddb-es,ddb"]; } }; diff --git a/scripts/release/index.js b/scripts/release/index.js index b43ebc76a97..f1ad64f66b0 100644 --- a/scripts/release/index.js +++ b/scripts/release/index.js @@ -6,7 +6,7 @@ const { getReleaseType } = require("./releaseTypes"); yargs.version(false); async function runRelease() { - const { type, tag, gitReset, version, createGithubRelease, printVersion } = yargs.argv; + const { type, tag, gitReset = true, version, createGithubRelease, printVersion } = yargs.argv; console.log({ type, tag, gitReset, version }); if (!type) { @@ -26,9 +26,7 @@ async function runRelease() { release.setVersion(version); } - if (gitReset) { - release.setResetAllChanges(gitReset); - } + release.setResetAllChanges(Boolean(gitReset)); if (createGithubRelease) { release.setCreateGithubRelease(createGithubRelease); diff --git a/typings/env/index.d.ts b/typings/env/index.d.ts index 82c4d3c39e9..1b64a4c2ba7 100644 --- a/typings/env/index.d.ts +++ b/typings/env/index.d.ts @@ -49,6 +49,7 @@ declare namespace NodeJS { STAGED_ROLLOUTS_VARIANT?: string; ELASTIC_SEARCH_ENDPOINT?: string; EVENT_BUS?: string; + WEBINY_FUNCTION_TYPE?: string; /** * Okta */ diff --git a/yarn.lock b/yarn.lock index 9f12699bb6f..7322b4cd35b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -919,6 +919,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sfn@npm:^3.425.0": + version: 3.484.0 + resolution: "@aws-sdk/client-sfn@npm:3.484.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.484.0 + "@aws-sdk/core": 3.481.0 + "@aws-sdk/credential-provider-node": 3.484.0 + "@aws-sdk/middleware-host-header": 3.468.0 + "@aws-sdk/middleware-logger": 3.468.0 + "@aws-sdk/middleware-recursion-detection": 3.468.0 + "@aws-sdk/middleware-signing": 3.468.0 + "@aws-sdk/middleware-user-agent": 3.478.0 + "@aws-sdk/region-config-resolver": 3.484.0 + "@aws-sdk/types": 3.468.0 + "@aws-sdk/util-endpoints": 3.478.0 + "@aws-sdk/util-user-agent-browser": 3.468.0 + "@aws-sdk/util-user-agent-node": 3.470.0 + "@smithy/config-resolver": ^2.0.22 + "@smithy/core": ^1.2.1 + "@smithy/fetch-http-handler": ^2.3.1 + "@smithy/hash-node": ^2.0.17 + "@smithy/invalid-dependency": ^2.0.15 + "@smithy/middleware-content-length": ^2.0.17 + "@smithy/middleware-endpoint": ^2.2.3 + "@smithy/middleware-retry": ^2.0.25 + "@smithy/middleware-serde": ^2.0.15 + "@smithy/middleware-stack": ^2.0.9 + "@smithy/node-config-provider": ^2.1.8 + "@smithy/node-http-handler": ^2.2.1 + "@smithy/protocol-http": ^3.0.11 + "@smithy/smithy-client": ^2.2.0 + "@smithy/types": ^2.7.0 + "@smithy/url-parser": ^2.0.15 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.23 + "@smithy/util-defaults-mode-node": ^2.0.31 + "@smithy/util-endpoints": ^1.0.7 + "@smithy/util-retry": ^2.0.8 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + uuid: ^8.3.2 + checksum: b5afc4a19ff62a9c71a7f53b81d0bd5a532d8bebd7d3febbb529c49f6a23b78b035adfa3eb55fdafb88bf3b2845f4c6a265ae6219830392d0754109d93256a39 + languageName: node + linkType: hard + "@aws-sdk/client-sqs@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/client-sqs@npm:3.425.0" @@ -1009,6 +1058,51 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/client-sso@npm:3.484.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.481.0 + "@aws-sdk/middleware-host-header": 3.468.0 + "@aws-sdk/middleware-logger": 3.468.0 + "@aws-sdk/middleware-recursion-detection": 3.468.0 + "@aws-sdk/middleware-user-agent": 3.478.0 + "@aws-sdk/region-config-resolver": 3.484.0 + "@aws-sdk/types": 3.468.0 + "@aws-sdk/util-endpoints": 3.478.0 + "@aws-sdk/util-user-agent-browser": 3.468.0 + "@aws-sdk/util-user-agent-node": 3.470.0 + "@smithy/config-resolver": ^2.0.22 + "@smithy/core": ^1.2.1 + "@smithy/fetch-http-handler": ^2.3.1 + "@smithy/hash-node": ^2.0.17 + "@smithy/invalid-dependency": ^2.0.15 + "@smithy/middleware-content-length": ^2.0.17 + "@smithy/middleware-endpoint": ^2.2.3 + "@smithy/middleware-retry": ^2.0.25 + "@smithy/middleware-serde": ^2.0.15 + "@smithy/middleware-stack": ^2.0.9 + "@smithy/node-config-provider": ^2.1.8 + "@smithy/node-http-handler": ^2.2.1 + "@smithy/protocol-http": ^3.0.11 + "@smithy/smithy-client": ^2.2.0 + "@smithy/types": ^2.7.0 + "@smithy/url-parser": ^2.0.15 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.23 + "@smithy/util-defaults-mode-node": ^2.0.31 + "@smithy/util-endpoints": ^1.0.7 + "@smithy/util-retry": ^2.0.8 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 53f8c3e5bf0f995767d37e269edfc23a8d048e17518c05978bade6726f8d16504da6028de24ce8eac7c0ab5e2a14146c67047ada631de0db93606a56f92cadb9 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.425.0, @aws-sdk/client-sts@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/client-sts@npm:3.425.0" @@ -1055,6 +1149,54 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/client-sts@npm:3.484.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.481.0 + "@aws-sdk/credential-provider-node": 3.484.0 + "@aws-sdk/middleware-host-header": 3.468.0 + "@aws-sdk/middleware-logger": 3.468.0 + "@aws-sdk/middleware-recursion-detection": 3.468.0 + "@aws-sdk/middleware-user-agent": 3.478.0 + "@aws-sdk/region-config-resolver": 3.484.0 + "@aws-sdk/types": 3.468.0 + "@aws-sdk/util-endpoints": 3.478.0 + "@aws-sdk/util-user-agent-browser": 3.468.0 + "@aws-sdk/util-user-agent-node": 3.470.0 + "@smithy/config-resolver": ^2.0.22 + "@smithy/core": ^1.2.1 + "@smithy/fetch-http-handler": ^2.3.1 + "@smithy/hash-node": ^2.0.17 + "@smithy/invalid-dependency": ^2.0.15 + "@smithy/middleware-content-length": ^2.0.17 + "@smithy/middleware-endpoint": ^2.2.3 + "@smithy/middleware-retry": ^2.0.25 + "@smithy/middleware-serde": ^2.0.15 + "@smithy/middleware-stack": ^2.0.9 + "@smithy/node-config-provider": ^2.1.8 + "@smithy/node-http-handler": ^2.2.1 + "@smithy/protocol-http": ^3.0.11 + "@smithy/smithy-client": ^2.2.0 + "@smithy/types": ^2.7.0 + "@smithy/url-parser": ^2.0.15 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.23 + "@smithy/util-defaults-mode-node": ^2.0.31 + "@smithy/util-endpoints": ^1.0.7 + "@smithy/util-middleware": ^2.0.8 + "@smithy/util-retry": ^2.0.8 + "@smithy/util-utf8": ^2.0.2 + fast-xml-parser: 4.2.5 + tslib: ^2.5.0 + checksum: 5ccb4f981cd1405450e269a983ceea43ff006212a34774b4ef88ec0f39a4937384a2dda5f0e0863586d9b7ad8b952f65bbb42e5ea6714bedd7dc6412bf2a315f + languageName: node + linkType: hard + "@aws-sdk/config-resolver@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/config-resolver@npm:3.6.1" @@ -1066,6 +1208,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.481.0": + version: 3.481.0 + resolution: "@aws-sdk/core@npm:3.481.0" + dependencies: + "@smithy/core": ^1.2.1 + "@smithy/protocol-http": ^3.0.11 + "@smithy/signature-v4": ^2.0.0 + "@smithy/smithy-client": ^2.2.0 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: d8dbd5f04043a7dc18d59b74c7141583bfe737f062c80624f05a45abfeb80490ba32f8f9451b12d5f222759d9add46d47d9ae1205f089ba5a36f712a4e8e54c1 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-cognito-identity@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.425.0" @@ -1103,6 +1259,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: dd378030e6268caad7b7523dd63dafe223b1482c6744f7320ec737eb308eb46111deb5d28c6e5450a93c79cccccb5223b8debc3eccfcc3e012c39ebc78123fe8 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-env@npm:3.6.1" @@ -1158,6 +1326,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.484.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.468.0 + "@aws-sdk/credential-provider-process": 3.468.0 + "@aws-sdk/credential-provider-sso": 3.484.0 + "@aws-sdk/credential-provider-web-identity": 3.468.0 + "@aws-sdk/types": 3.468.0 + "@smithy/credential-provider-imds": ^2.0.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 97027686f3f66c799c90cb1aba4c9482a40b72ad3cef242e52c50c34a30fd91cfac1c08b39447fa73f3ce2b4826bbbf55460f0fa71c17718a1bafe626845c101 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-ini@npm:3.6.1" @@ -1189,6 +1375,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.484.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.468.0 + "@aws-sdk/credential-provider-ini": 3.484.0 + "@aws-sdk/credential-provider-process": 3.468.0 + "@aws-sdk/credential-provider-sso": 3.484.0 + "@aws-sdk/credential-provider-web-identity": 3.468.0 + "@aws-sdk/types": 3.468.0 + "@smithy/credential-provider-imds": ^2.0.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 3f6712c6b5cd965ff8b711a4fab686f8fac43218dd4e9ec38886eb0566db9cd3200f92d3e5615c07e7d06ab0b95241206b224c467181d95f87fb6623260cf100 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-node@npm:3.6.1" @@ -1218,6 +1423,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 8226e35a2a829d2278f7064174f99e0bf1747992b6f55393be5d6e0be84bedb075528a0d28213457f9d360aaa7cbded93e6ea37fc3160fc5abf408b089f878cb + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/credential-provider-process@npm:3.6.1" @@ -1246,6 +1464,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.484.0" + dependencies: + "@aws-sdk/client-sso": 3.484.0 + "@aws-sdk/token-providers": 3.484.0 + "@aws-sdk/types": 3.468.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 3bb7912b2a0e5fe752c7e4e71203cc3f2c0bbdf25c1456f0b504e3e0cbef9120bbf34437643c1a6b43583a5dd845b54107dca53973e777d3ecbde61dcd1b8469 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.425.0" @@ -1258,6 +1491,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 388ad2093341916750b02cb5617ff288d670c706582c23e80be1547f7bfe4fb28de011bc14bae931901ecfa91e0a39a54b5ef3130f29f739cc3d4d64aca9bb70 + languageName: node + linkType: hard + "@aws-sdk/credential-providers@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/credential-providers@npm:3.425.0" @@ -1444,6 +1689,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-host-header@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/protocol-http": ^3.0.11 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: de2836c970c8345175a9b6f07bf81fe65dfc0bbb39e81cb67112309a2e3536605cd442e6a6ea68ef171392b931fff12a16aa2c7fb0ab04a1e5144ddc4796f485 + languageName: node + linkType: hard + "@aws-sdk/middleware-host-header@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-host-header@npm:3.6.1" @@ -1477,6 +1734,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-logger@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/middleware-logger@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 22b8d8ed7bccec202a902218041d46a24c384b81f5c73c6674355c7a1e2c69e46161b2c28ac77eee38a072b402036fb249eed4840a3badede3a50983db5a6ac4 + languageName: node + linkType: hard + "@aws-sdk/middleware-logger@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-logger@npm:3.6.1" @@ -1499,6 +1767,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-recursion-detection@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/protocol-http": ^3.0.11 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: 209b2e59447f2658a90a33b60a1b0dfd37c48f54c67f2f2946bcba6ab87cd29af8edf52b71c5ee7324931aece7b53d42e58ccd51a146d32d01e1c9c47c1b45d4 + languageName: node + linkType: hard + "@aws-sdk/middleware-retry@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-retry@npm:3.6.1" @@ -1577,6 +1857,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-signing@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/middleware-signing@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/protocol-http": ^3.0.11 + "@smithy/signature-v4": ^2.0.0 + "@smithy/types": ^2.7.0 + "@smithy/util-middleware": ^2.0.8 + tslib: ^2.5.0 + checksum: fa913c7cb5f669fae12cc76c4ff115d7d20b1c63af6295df0f5f0fb1a8ecfc4038ee3bddbc5844deeb9c255f690ba6c756a288d086cbbd6136b3d749ff97a65c + languageName: node + linkType: hard + "@aws-sdk/middleware-signing@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-signing@npm:3.6.1" @@ -1622,6 +1917,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:3.478.0": + version: 3.478.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.478.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@aws-sdk/util-endpoints": 3.478.0 + "@smithy/protocol-http": ^3.0.11 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: bde11dc16e2c02669be0745a02936f19441dc8b0fd1aa6f9f84467843eb0574b1e0b26752a6bd0d89044d406f510949da316ecc9b463f498e8c0cab1bb2a3b2b + languageName: node + linkType: hard + "@aws-sdk/middleware-user-agent@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/middleware-user-agent@npm:3.6.1" @@ -1712,6 +2020,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/region-config-resolver@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.484.0" + dependencies: + "@smithy/node-config-provider": ^2.1.8 + "@smithy/types": ^2.7.0 + "@smithy/util-config-provider": ^2.1.0 + "@smithy/util-middleware": ^2.0.8 + tslib: ^2.5.0 + checksum: 861a4672051cc9ac0dbff43f1386467264e6489df71459a46e7d63c72441b1aaaad99ba33b9327952b6447ce0d7e0e6198c4e4e75c875d09a1277d28dd281f60 + languageName: node + linkType: hard + "@aws-sdk/s3-presigned-post@npm:^3.425.0": version: 3.425.0 resolution: "@aws-sdk/s3-presigned-post@npm:3.425.0" @@ -1841,6 +2162,51 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.484.0": + version: 3.484.0 + resolution: "@aws-sdk/token-providers@npm:3.484.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/middleware-host-header": 3.468.0 + "@aws-sdk/middleware-logger": 3.468.0 + "@aws-sdk/middleware-recursion-detection": 3.468.0 + "@aws-sdk/middleware-user-agent": 3.478.0 + "@aws-sdk/region-config-resolver": 3.484.0 + "@aws-sdk/types": 3.468.0 + "@aws-sdk/util-endpoints": 3.478.0 + "@aws-sdk/util-user-agent-browser": 3.468.0 + "@aws-sdk/util-user-agent-node": 3.470.0 + "@smithy/config-resolver": ^2.0.22 + "@smithy/fetch-http-handler": ^2.3.1 + "@smithy/hash-node": ^2.0.17 + "@smithy/invalid-dependency": ^2.0.15 + "@smithy/middleware-content-length": ^2.0.17 + "@smithy/middleware-endpoint": ^2.2.3 + "@smithy/middleware-retry": ^2.0.25 + "@smithy/middleware-serde": ^2.0.15 + "@smithy/middleware-stack": ^2.0.9 + "@smithy/node-config-provider": ^2.1.8 + "@smithy/node-http-handler": ^2.2.1 + "@smithy/property-provider": ^2.0.0 + "@smithy/protocol-http": ^3.0.11 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/smithy-client": ^2.2.0 + "@smithy/types": ^2.7.0 + "@smithy/url-parser": ^2.0.15 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.23 + "@smithy/util-defaults-mode-node": ^2.0.31 + "@smithy/util-endpoints": ^1.0.7 + "@smithy/util-retry": ^2.0.8 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: e5bf31fe47632e0dac585ad059f5ba14701867f3520724a2548496fa71caa0c3ee22aa42b5add4881acf4b5b55e7978816f9941bbf334df55e88b1e54b77d0a6 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/types@npm:3.425.0" @@ -1851,6 +2217,16 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/types@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/types@npm:3.468.0" + dependencies: + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + checksum: f30ecfbdf6deac44d75d8575f034169a11f5be131228bab8ce78a91105d813617edb6d9492dbf266be713e1bc8978029f3fa42c17c2268732378e1c3356ad583 + languageName: node + linkType: hard + "@aws-sdk/types@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/types@npm:3.6.1" @@ -1967,6 +2343,17 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-endpoints@npm:3.478.0": + version: 3.478.0 + resolution: "@aws-sdk/util-endpoints@npm:3.478.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/util-endpoints": ^1.0.7 + tslib: ^2.5.0 + checksum: e19334b19f085a828f20f23769e1e2e3edb518e28a15b5667a28b18db345ff12d2aca1f413479a4bb441e92c1b392a73240c66eb9e7850b572569abcef1c283c + languageName: node + linkType: hard + "@aws-sdk/util-format-url@npm:3.425.0": version: 3.425.0 resolution: "@aws-sdk/util-format-url@npm:3.425.0" @@ -2018,6 +2405,18 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-browser@npm:3.468.0": + version: 3.468.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.468.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/types": ^2.7.0 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: 40edf9f88336f70567fc1a6887ea9724dffcb1caf9e18cd0c402d3250ad8dad1e5604c3007a93c6a4fcf404e026607922352fb89b3819779d354308609d08a86 + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-browser@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/util-user-agent-browser@npm:3.6.1" @@ -2046,6 +2445,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:3.470.0": + version: 3.470.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.470.0" + dependencies: + "@aws-sdk/types": 3.468.0 + "@smithy/node-config-provider": ^2.1.8 + "@smithy/types": ^2.7.0 + tslib: ^2.5.0 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 11fe4ae2e437edb9bb0cb34bce60ea4d4fb2f6108b3c45918f67aa5c93b14292df278e878d9b5dbc5a27ceec73b33d51d044975d16cc031d2b431044f6893629 + languageName: node + linkType: hard + "@aws-sdk/util-user-agent-node@npm:3.6.1": version: 3.6.1 resolution: "@aws-sdk/util-user-agent-node@npm:3.6.1" @@ -10470,6 +10886,16 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/abort-controller@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f91824aa8c8c39223b2d52c88fe123b36e5823acf8ce9316ce3b38245202cc6d31e1c172ebfec9fda47466d0b31f1df1add7e64d7161695fd27f96992037b18f + languageName: node + linkType: hard + "@smithy/chunked-blob-reader-native@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/chunked-blob-reader-native@npm:2.0.0" @@ -10502,6 +10928,35 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^2.0.22, @smithy/config-resolver@npm:^2.0.23": + version: 2.0.23 + resolution: "@smithy/config-resolver@npm:2.0.23" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + "@smithy/util-config-provider": ^2.1.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 16f6c9a492aca44acc3f2dbb9c92e9212daad741143b444333926befb373ebe355edb62e74cf4af6b54cea54e4b54614a985c5dcb51e127be02e59e35c8d8815 + languageName: node + linkType: hard + +"@smithy/core@npm:^1.2.1": + version: 1.2.2 + resolution: "@smithy/core@npm:1.2.2" + dependencies: + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/protocol-http": ^3.0.12 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 5688b08bf935f429ada15c1238b0e6046069418cfb1810981e04d4dc00a9552189cfe566e23f8a51579894e2de44dc3d8548aca4ff6a3e16f385a478e3fb2b18 + languageName: node + linkType: hard + "@smithy/credential-provider-imds@npm:^2.0.0": version: 2.0.12 resolution: "@smithy/credential-provider-imds@npm:2.0.12" @@ -10528,6 +10983,19 @@ __metadata: languageName: node linkType: hard +"@smithy/credential-provider-imds@npm:^2.1.5": + version: 2.1.5 + resolution: "@smithy/credential-provider-imds@npm:2.1.5" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/property-provider": ^2.0.17 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + tslib: ^2.5.0 + checksum: 1df3be00235960bc3d6a1703c4d3446d2338ee3684e0f17d2cbefc115ca38e6c1015d0e17116551e59e5adfd02a5923a8dddb83a956e197fd5aa4308ab20acdd + languageName: node + linkType: hard + "@smithy/eventstream-codec@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/eventstream-codec@npm:2.0.10" @@ -10621,6 +11089,19 @@ __metadata: languageName: node linkType: hard +"@smithy/fetch-http-handler@npm:^2.3.1, @smithy/fetch-http-handler@npm:^2.3.2": + version: 2.3.2 + resolution: "@smithy/fetch-http-handler@npm:2.3.2" + dependencies: + "@smithy/protocol-http": ^3.0.12 + "@smithy/querystring-builder": ^2.0.16 + "@smithy/types": ^2.8.0 + "@smithy/util-base64": ^2.0.1 + tslib: ^2.5.0 + checksum: 883fcfae5ffcc616229dc982a48bf6c44984f652f2ef4ca9d233bfb3c4726fbb54e6a39d78fc410fda718c22a0a0e72a5207a4a4e202755c1ccd8760a0d1d821 + languageName: node + linkType: hard + "@smithy/hash-blob-browser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/hash-blob-browser@npm:2.0.10" @@ -10645,6 +11126,18 @@ __metadata: languageName: node linkType: hard +"@smithy/hash-node@npm:^2.0.17": + version: 2.0.18 + resolution: "@smithy/hash-node@npm:2.0.18" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-buffer-from": ^2.0.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 1f40ae7b38808b1836c8e166f5182fcbc09423a833728943ab1edbc019dbd83323c9a08a29a2d73cd4ed5b3483e87158f8d4cc8ee5c6c88909c1dcde9a1b9826 + languageName: node + linkType: hard + "@smithy/hash-stream-node@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/hash-stream-node@npm:2.0.10" @@ -10666,6 +11159,16 @@ __metadata: languageName: node linkType: hard +"@smithy/invalid-dependency@npm:^2.0.15": + version: 2.0.16 + resolution: "@smithy/invalid-dependency@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 1ddc4dfb3740fb6229d20d3074e0c59a6ebafd1f3b31f01eb630fc5ee360e60f495773cae6fc5d357873b6b34ab2d89d58b0887fea4e57f1e1f26d02ab234e5c + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/is-array-buffer@npm:2.0.0" @@ -10697,6 +11200,17 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-content-length@npm:^2.0.17": + version: 2.0.18 + resolution: "@smithy/middleware-content-length@npm:2.0.18" + dependencies: + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: bbbd3e69e065c1677150a61af2303c3fda35c7fce527fd594f63be00421daecb7fedfd135945b3ef8104cd5305367f7d8e235e704d6743c5fa5d6c0178a35de3 + languageName: node + linkType: hard + "@smithy/middleware-endpoint@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/middleware-endpoint@npm:2.0.10" @@ -10725,6 +11239,21 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-endpoint@npm:^2.2.3, @smithy/middleware-endpoint@npm:^2.3.0": + version: 2.3.0 + resolution: "@smithy/middleware-endpoint@npm:2.3.0" + dependencies: + "@smithy/middleware-serde": ^2.0.16 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/shared-ini-file-loader": ^2.2.8 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 07512e57190dd9a063359934d6c688bfdc3849657a3d7b91a4194d4b19ca94ad67a5344f0418462147b105ce6d7d8adc895a1ac9d6aefc80937643cdea70e14e + languageName: node + linkType: hard + "@smithy/middleware-retry@npm:^2.0.13": version: 2.0.13 resolution: "@smithy/middleware-retry@npm:2.0.13" @@ -10741,6 +11270,23 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-retry@npm:^2.0.25, @smithy/middleware-retry@npm:^2.0.26": + version: 2.0.26 + resolution: "@smithy/middleware-retry@npm:2.0.26" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/protocol-http": ^3.0.12 + "@smithy/service-error-classification": ^2.0.9 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/util-middleware": ^2.0.9 + "@smithy/util-retry": ^2.0.9 + tslib: ^2.5.0 + uuid: ^8.3.2 + checksum: e33d87b539776398e1b5af99e0a0659534194f691c16aaafc22c8ef0ee6fcc2568e3e8bbcb79ad9f114c164f956530b96393f3b25ee15773a23c3f8a5df00e62 + languageName: node + linkType: hard + "@smithy/middleware-serde@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/middleware-serde@npm:2.0.10" @@ -10761,6 +11307,26 @@ __metadata: languageName: node linkType: hard +"@smithy/middleware-serde@npm:^2.0.15, @smithy/middleware-serde@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/middleware-serde@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 64cad569c02bfb53fda13189cb24db72e35e25de058a6d4b51d9e9c89bc97f7aba7373787fb48ad32226b3d3d012d1554378c7c22a3f162f61e8e3fc1d069f5e + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^2.0.10, @smithy/middleware-stack@npm:^2.0.9": + version: 2.0.10 + resolution: "@smithy/middleware-stack@npm:2.0.10" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f2e491a10bf20d0605dfa0fd6e629b0fdcc821a863fb5b1f9ab7c02f2a3180869334da15739c4ad66fe8022c3f5ffb6868dec51023bff9b07977989db8164ebb + languageName: node + linkType: hard + "@smithy/middleware-stack@npm:^2.0.4": version: 2.0.4 resolution: "@smithy/middleware-stack@npm:2.0.4" @@ -10817,6 +11383,18 @@ __metadata: languageName: node linkType: hard +"@smithy/node-config-provider@npm:^2.1.8, @smithy/node-config-provider@npm:^2.1.9": + version: 2.1.9 + resolution: "@smithy/node-config-provider@npm:2.1.9" + dependencies: + "@smithy/property-provider": ^2.0.17 + "@smithy/shared-ini-file-loader": ^2.2.8 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 993c87c85b88671e8dab3d9443f7f832b585e34c5578f655168162e968b14f4f7f5dd04515364a9c7155aedd6dd175f7fb6a14c2318c81b01dd3245086b8e5f1 + languageName: node + linkType: hard + "@smithy/node-http-handler@npm:^2.1.6": version: 2.1.6 resolution: "@smithy/node-http-handler@npm:2.1.6" @@ -10843,6 +11421,19 @@ __metadata: languageName: node linkType: hard +"@smithy/node-http-handler@npm:^2.2.1, @smithy/node-http-handler@npm:^2.2.2": + version: 2.2.2 + resolution: "@smithy/node-http-handler@npm:2.2.2" + dependencies: + "@smithy/abort-controller": ^2.0.16 + "@smithy/protocol-http": ^3.0.12 + "@smithy/querystring-builder": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 891532bfb6c1d3f3aed710bf8ed922706270effc8d8d00d4133e6271f58525ed9ccf7d03e16c0baf7a4e8b2768d5e38b92beca23f9b6aac8e02e8bc7c6769a81 + languageName: node + linkType: hard + "@smithy/property-provider@npm:^2.0.0, @smithy/property-provider@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/property-provider@npm:2.0.10" @@ -10873,6 +11464,26 @@ __metadata: languageName: node linkType: hard +"@smithy/property-provider@npm:^2.0.17": + version: 2.0.17 + resolution: "@smithy/property-provider@npm:2.0.17" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: ecf2c909fd365cfe59bece32538131ffb2bcdc55a2a287722b1eefcac4c83de7f7f7dc92e4829cdbb74cc3c15f6fad8617eb97ec97ed1fc7ca9180ba7f758229 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^3.0.11, @smithy/protocol-http@npm:^3.0.12": + version: 3.0.12 + resolution: "@smithy/protocol-http@npm:3.0.12" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 38899820a59ebcdf4784b16d6a02fa630441a76857f204e479bd8c59ec7c578e5107e995fc0b1b9dee444b9610bd9bdf00ccf7e1c806ff463c7e9402660748e1 + languageName: node + linkType: hard + "@smithy/protocol-http@npm:^3.0.6": version: 3.0.6 resolution: "@smithy/protocol-http@npm:3.0.6" @@ -10915,6 +11526,17 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-builder@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/querystring-builder@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-uri-escape": ^2.0.0 + tslib: ^2.5.0 + checksum: d88983a2088dd2d00dced8e122ee20c278edf00f80ff79485187efb178d3c1a00e679f5059c605d914d646dac37e35fd458bbc20a56da0e2ac6e168dc2a8f91b + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/querystring-parser@npm:2.0.10" @@ -10935,6 +11557,16 @@ __metadata: languageName: node linkType: hard +"@smithy/querystring-parser@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/querystring-parser@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f00fcfa102838a32afa43b3e4e9dd706f1d79e09778767f089f97ee47809d47e9dd6681618dac0903913aa7ae64479ee7be9269c5e7e7e0b29527ea63cde3e26 + languageName: node + linkType: hard + "@smithy/querystring-parser@npm:^2.0.9": version: 2.0.9 resolution: "@smithy/querystring-parser@npm:2.0.9" @@ -10954,6 +11586,15 @@ __metadata: languageName: node linkType: hard +"@smithy/service-error-classification@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/service-error-classification@npm:2.0.9" + dependencies: + "@smithy/types": ^2.8.0 + checksum: 76f4fcae188e6d318d09e9974d6b563cb1329d66788d6ada882974cf3c59046b174164b35d2a9239a8cc8747a07a81831e44b4032752ad220819cd8495962c90 + languageName: node + linkType: hard + "@smithy/shared-ini-file-loader@npm:^2.0.11, @smithy/shared-ini-file-loader@npm:^2.0.6": version: 2.0.11 resolution: "@smithy/shared-ini-file-loader@npm:2.0.11" @@ -10984,6 +11625,16 @@ __metadata: languageName: node linkType: hard +"@smithy/shared-ini-file-loader@npm:^2.2.8": + version: 2.2.8 + resolution: "@smithy/shared-ini-file-loader@npm:2.2.8" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 768b57b0f783e7ce9df08e23de0cbfb85e686dcd7f0014cf78747696247941ded20a79df84ab30fe96a927ab51c937264b7e70d90a94d4ac32a44972569cb4f7 + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^2.0.0": version: 2.0.9 resolution: "@smithy/signature-v4@npm:2.0.9" @@ -11024,6 +11675,20 @@ __metadata: languageName: node linkType: hard +"@smithy/smithy-client@npm:^2.2.0, @smithy/smithy-client@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/smithy-client@npm:2.2.1" + dependencies: + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + "@smithy/util-stream": ^2.0.24 + tslib: ^2.5.0 + checksum: dda90afce6f3260217967c184065a3b07be699102a36e64357124394c7a075496e40d18e7b0d23f1204748777fcc08b7d51d8f0170e877a32dd306a4ff757748 + languageName: node + linkType: hard + "@smithy/types@npm:^2.3.3": version: 2.3.3 resolution: "@smithy/types@npm:2.3.3" @@ -11051,6 +11716,15 @@ __metadata: languageName: node linkType: hard +"@smithy/types@npm:^2.7.0, @smithy/types@npm:^2.8.0": + version: 2.8.0 + resolution: "@smithy/types@npm:2.8.0" + dependencies: + tslib: ^2.5.0 + checksum: c0c85b1422f982635c8b5c6477f5d2b28a5afeaf21f6e877dc1f96e401673632b5abaf3b49f800f1859119c498151e5a59e0361c8f56945f79642c486ac68af8 + languageName: node + linkType: hard + "@smithy/url-parser@npm:^2.0.10": version: 2.0.10 resolution: "@smithy/url-parser@npm:2.0.10" @@ -11073,6 +11747,17 @@ __metadata: languageName: node linkType: hard +"@smithy/url-parser@npm:^2.0.15, @smithy/url-parser@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/url-parser@npm:2.0.16" + dependencies: + "@smithy/querystring-parser": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 05d9cca95307f3acd53e3a31af96b83e2351e3f64b68672afdda32b5aa6d902c05f2567b2a683ed32132c152e0b8d38200c803f63f9e8c983b976ccf999fcdc4 + languageName: node + linkType: hard + "@smithy/url-parser@npm:^2.0.9": version: 2.0.9 resolution: "@smithy/url-parser@npm:2.0.9" @@ -11113,6 +11798,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-body-length-browser@npm:^2.0.1": + version: 2.0.1 + resolution: "@smithy/util-body-length-browser@npm:2.0.1" + dependencies: + tslib: ^2.5.0 + checksum: 1d342acdba493047400a1aae9922e7274a2d4ba68f2980290ac4d44bd1a33a2a0a9d75b99c773924a7381d88c7b8cc612947e3adb442f7f67ac2edd4a4d3cf58 + languageName: node + linkType: hard + "@smithy/util-body-length-node@npm:^2.1.0": version: 2.1.0 resolution: "@smithy/util-body-length-node@npm:2.1.0" @@ -11141,6 +11835,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-config-provider@npm:^2.1.0": + version: 2.1.0 + resolution: "@smithy/util-config-provider@npm:2.1.0" + dependencies: + tslib: ^2.5.0 + checksum: bd8b677fdf1891e5ec97f6fe0ab3e798ed005fd56c3868d6f529f523fbf077999f6af04295142be7f6d87551920ae0a455a6c74f3e4de972e8cd2070b569a5b1 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-browser@npm:^2.0.13": version: 2.0.13 resolution: "@smithy/util-defaults-mode-browser@npm:2.0.13" @@ -11154,6 +11857,19 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-browser@npm:^2.0.23": + version: 2.0.24 + resolution: "@smithy/util-defaults-mode-browser@npm:2.0.24" + dependencies: + "@smithy/property-provider": ^2.0.17 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: 711f08ac4762b70ce91fd29592228d9c2a48b2a10ea04796a4340d9eae08981d82bea26730ee740dd77381cba59a6e449556417dbbb1fa8bf089d2c649a62af4 + languageName: node + linkType: hard + "@smithy/util-defaults-mode-node@npm:^2.0.15": version: 2.0.15 resolution: "@smithy/util-defaults-mode-node@npm:2.0.15" @@ -11169,6 +11885,32 @@ __metadata: languageName: node linkType: hard +"@smithy/util-defaults-mode-node@npm:^2.0.31": + version: 2.0.32 + resolution: "@smithy/util-defaults-mode-node@npm:2.0.32" + dependencies: + "@smithy/config-resolver": ^2.0.23 + "@smithy/credential-provider-imds": ^2.1.5 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/property-provider": ^2.0.17 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 3bccae4e22307a25c0f5c0718dec6d0a1349586a64948e6211f2ba05bd02e226db87949e653ac34333f0646cc169b4c330fdaf102b594ea95c191acba5a2cbef + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^1.0.7": + version: 1.0.8 + resolution: "@smithy/util-endpoints@npm:1.0.8" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 8133e253f390ea1456e2f13f1853f258827497042109f2933f1c7fc47307f865e490ba7fafc22f6abacf609e10e17b67f2d62c0c48f8d88f3b9e94de4e88ff62 + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-hex-encoding@npm:2.0.0" @@ -11208,6 +11950,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-middleware@npm:^2.0.8, @smithy/util-middleware@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/util-middleware@npm:2.0.9" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 0c34552d6845ef215441602a16ac6e02e2a5a8ab70e1b2ed0dc37a7acbf5de6c7f2de9ba09f303c2bfbfa77a0a4869f6660874c9f6daeed757392fb42466e01a + languageName: node + linkType: hard + "@smithy/util-retry@npm:^2.0.3": version: 2.0.3 resolution: "@smithy/util-retry@npm:2.0.3" @@ -11219,6 +11971,17 @@ __metadata: languageName: node linkType: hard +"@smithy/util-retry@npm:^2.0.8, @smithy/util-retry@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/util-retry@npm:2.0.9" + dependencies: + "@smithy/service-error-classification": ^2.0.9 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 40385b48c846e2c8d79531789d6bbbd3c7342c607b7227a8c5e873b481e9425705927b26c65e7b71b20ff851e9b54d036b279a485c2a13a828359b7ef6d36fa4 + languageName: node + linkType: hard + "@smithy/util-stream@npm:^2.0.14": version: 2.0.14 resolution: "@smithy/util-stream@npm:2.0.14" @@ -11251,6 +12014,22 @@ __metadata: languageName: node linkType: hard +"@smithy/util-stream@npm:^2.0.24": + version: 2.0.24 + resolution: "@smithy/util-stream@npm:2.0.24" + dependencies: + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/types": ^2.8.0 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-buffer-from": ^2.0.0 + "@smithy/util-hex-encoding": ^2.0.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 097e6be8f59d166a5611f09a7823b55a1cf42726ac7c48ac93d8a3d5f1f5423ee6e74fda1081f49e03802f2f788646d5db50ae74798e9644e4db642452dfa101 + languageName: node + linkType: hard + "@smithy/util-uri-escape@npm:^2.0.0": version: 2.0.0 resolution: "@smithy/util-uri-escape@npm:2.0.0" @@ -14128,6 +14907,56 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-background-tasks-ddb@0.0.0, @webiny/api-background-tasks-ddb@workspace:packages/api-background-tasks-ddb": + version: 0.0.0-use.local + resolution: "@webiny/api-background-tasks-ddb@workspace:packages/api-background-tasks-ddb" + dependencies: + "@babel/cli": ^7.22.6 + "@babel/core": ^7.22.8 + "@webiny/cli": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 + rimraf: ^3.0.2 + ttypescript: ^1.5.12 + typescript: 4.7.4 + languageName: unknown + linkType: soft + +"@webiny/api-background-tasks-es@workspace:packages/api-background-tasks-es": + version: 0.0.0-use.local + resolution: "@webiny/api-background-tasks-es@workspace:packages/api-background-tasks-es" + dependencies: + "@babel/cli": ^7.22.6 + "@babel/core": ^7.22.8 + "@webiny/api-elasticsearch-tasks": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 + rimraf: ^3.0.2 + ttypescript: ^1.5.12 + typescript: 4.7.4 + languageName: unknown + linkType: soft + +"@webiny/api-background-tasks-os@workspace:packages/api-background-tasks-os": + version: 0.0.0-use.local + resolution: "@webiny/api-background-tasks-os@workspace:packages/api-background-tasks-os" + dependencies: + "@babel/cli": ^7.22.6 + "@babel/core": ^7.22.8 + "@webiny/api-elasticsearch-tasks": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 + rimraf: ^3.0.2 + ttypescript: ^1.5.12 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/api-cognito-authenticator@0.0.0, @webiny/api-cognito-authenticator@workspace:packages/api-cognito-authenticator": version: 0.0.0-use.local resolution: "@webiny/api-cognito-authenticator@workspace:packages/api-cognito-authenticator" @@ -14171,6 +15000,40 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-elasticsearch-tasks@0.0.0, @webiny/api-elasticsearch-tasks@workspace:packages/api-elasticsearch-tasks": + version: 0.0.0-use.local + resolution: "@webiny/api-elasticsearch-tasks@workspace:packages/api-elasticsearch-tasks" + dependencies: + "@babel/cli": ^7.22.6 + "@babel/core": ^7.22.8 + "@babel/preset-env": ^7.22.7 + "@babel/preset-typescript": ^7.22.5 + "@babel/runtime": ^7.22.6 + "@webiny/api": 0.0.0 + "@webiny/api-elasticsearch": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-i18n": 0.0.0 + "@webiny/api-security": 0.0.0 + "@webiny/api-tenancy": 0.0.0 + "@webiny/api-wcp": 0.0.0 + "@webiny/aws-sdk": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/db-dynamodb": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 + "@webiny/handler-aws": 0.0.0 + "@webiny/handler-db": 0.0.0 + "@webiny/handler-graphql": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 + rimraf: ^3.0.2 + ttypescript: ^1.5.13 + type-fest: ^2.19.0 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/api-elasticsearch@0.0.0, @webiny/api-elasticsearch@workspace:packages/api-elasticsearch": version: 0.0.0-use.local resolution: "@webiny/api-elasticsearch@workspace:packages/api-elasticsearch" @@ -14232,18 +15095,22 @@ __metadata: "@webiny/aws-sdk": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 "@webiny/handler-graphql": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 "@webiny/utils": 0.0.0 "@webiny/validation": 0.0.0 form-data: ^4.0.0 mime: ^3.0.0 node-fetch: ^2.6.1 + object-hash: ^3.0.0 p-map: 4.0.0 p-reduce: 2.1.0 rimraf: ^3.0.2 sanitize-filename: ^1.6.3 + sharp: 0.32.6 typescript: 4.7.4 languageName: unknown linkType: soft @@ -14271,11 +15138,11 @@ __metadata: "@webiny/error": 0.0.0 "@webiny/handler": 0.0.0 "@webiny/handler-aws": 0.0.0 - "@webiny/handler-client": 0.0.0 "@webiny/handler-graphql": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/pubsub": 0.0.0 + "@webiny/tasks": 0.0.0 "@webiny/utils": 0.0.0 "@webiny/validation": 0.0.0 jest: ^29.5.0 @@ -15078,6 +15945,7 @@ __metadata: "@babel/preset-typescript": ^7.22.5 "@babel/runtime": ^7.22.6 "@commodo/fields": 1.1.2-beta.20 + "@types/jsonwebtoken": ^9.0.2 "@webiny/api": 0.0.0 "@webiny/api-authentication": 0.0.0 "@webiny/api-tenancy": 0.0.0 @@ -15097,7 +15965,8 @@ __metadata: "@webiny/validation": 0.0.0 "@webiny/wcp": 0.0.0 commodo-fields-object: ^1.0.6 - deep-equal: ^2.0.5 + deep-equal: ^2.2.3 + jsonwebtoken: ^9.0.1 minimatch: ^5.1.0 rimraf: ^3.0.2 ttypescript: ^1.5.12 @@ -15226,6 +16095,7 @@ __metadata: "@babel/preset-env": ^7.22.7 "@babel/preset-typescript": ^7.22.5 "@babel/runtime": ^7.22.6 + "@webiny/aws-sdk": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 @@ -16127,10 +16997,8 @@ __metadata: "@webiny/lexical-editor": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/theme": 0.0.0 - "@webiny/validation": 0.0.0 execa: ^5.0.0 facepaint: ^1.2.1 - lodash: ^4.17.21 rimraf: ^3.0.2 ttypescript: ^1.5.12 typescript: 4.7.4 @@ -16672,6 +17540,7 @@ __metadata: "@aws-sdk/client-iam": ^3.425.0 "@aws-sdk/client-lambda": ^3.425.0 "@aws-sdk/client-s3": ^3.425.0 + "@aws-sdk/client-sfn": ^3.425.0 "@aws-sdk/client-sqs": ^3.425.0 "@aws-sdk/client-sts": ^3.425.0 "@aws-sdk/credential-providers": ^3.425.0 @@ -17592,6 +18461,7 @@ __metadata: load-json-file: 6.2.0 lodash: ^4.6.2 mini-css-extract-plugin: 2.4.5 + null-loader: ^4.0.1 os-browserify: ^0.3.0 path-browserify: ^1.0.1 pino: ^8.11.0 @@ -17920,6 +18790,42 @@ __metadata: languageName: unknown linkType: soft +"@webiny/tasks@0.0.0, @webiny/tasks@workspace:packages/tasks": + version: 0.0.0-use.local + resolution: "@webiny/tasks@workspace:packages/tasks" + dependencies: + "@babel/cli": ^7.22.6 + "@babel/core": ^7.22.8 + "@babel/preset-env": ^7.22.7 + "@babel/preset-typescript": ^7.22.5 + "@babel/runtime": ^7.22.6 + "@webiny/api": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-i18n": 0.0.0 + "@webiny/api-security": 0.0.0 + "@webiny/api-tenancy": 0.0.0 + "@webiny/api-wcp": 0.0.0 + "@webiny/aws-sdk": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 + "@webiny/handler-aws": 0.0.0 + "@webiny/handler-graphql": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/pubsub": 0.0.0 + "@webiny/utils": 0.0.0 + aws-lambda: ^1.0.7 + deep-equal: ^2.2.3 + lodash: ^4.17.21 + rimraf: ^3.0.2 + ttypescript: ^1.5.13 + type-fest: ^2.19.0 + typescript: 4.7.4 + zod: ^3.21.4 + languageName: unknown + linkType: soft + "@webiny/telemetry@0.0.0, @webiny/telemetry@workspace:packages/telemetry": version: 0.0.0-use.local resolution: "@webiny/telemetry@workspace:packages/telemetry" @@ -18943,18 +19849,6 @@ __metadata: languageName: unknown linkType: soft -"api-file-manager-download@workspace:apps/api/fileManager/download": - version: 0.0.0-use.local - resolution: "api-file-manager-download@workspace:apps/api/fileManager/download" - dependencies: - "@webiny/api-file-manager": 0.0.0 - "@webiny/aws-sdk": 0.0.0 - "@webiny/cli": 0.0.0 - "@webiny/handler-aws": 0.0.0 - "@webiny/project-utils": 0.0.0 - languageName: unknown - linkType: soft - "api-file-manager-manage@workspace:apps/api/fileManager/manage": version: 0.0.0-use.local resolution: "api-file-manager-manage@workspace:apps/api/fileManager/manage" @@ -18966,17 +19860,6 @@ __metadata: languageName: unknown linkType: soft -"api-file-manager-transform@workspace:apps/api/fileManager/transform": - version: 0.0.0-use.local - resolution: "api-file-manager-transform@workspace:apps/api/fileManager/transform" - dependencies: - "@webiny/api-file-manager": 0.0.0 - "@webiny/cli": 0.0.0 - "@webiny/handler-aws": 0.0.0 - "@webiny/project-utils": 0.0.0 - languageName: unknown - linkType: soft - "api-graphql@workspace:apps/api/graphql": version: 0.0.0-use.local resolution: "api-graphql@workspace:apps/api/graphql" @@ -18987,6 +19870,7 @@ __metadata: "@webiny/api-apw": 0.0.0 "@webiny/api-apw-scheduler-so-ddb": 0.0.0 "@webiny/api-audit-logs": 0.0.0 + "@webiny/api-background-tasks-ddb": 0.0.0 "@webiny/api-file-manager": 0.0.0 "@webiny/api-file-manager-ddb": 0.0.0 "@webiny/api-file-manager-s3": 0.0.0 @@ -19020,6 +19904,7 @@ __metadata: "@webiny/handler-graphql": 0.0.0 "@webiny/handler-logs": 0.0.0 "@webiny/project-utils": 0.0.0 + "@webiny/tasks": 0.0.0 graphql-request: ^3.4.0 languageName: unknown linkType: soft @@ -21459,6 +22344,17 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.4, call-bind@npm:^1.0.5": + version: 1.0.5 + resolution: "call-bind@npm:1.0.5" + dependencies: + function-bind: ^1.1.2 + get-intrinsic: ^1.2.1 + set-function-length: ^1.1.1 + checksum: 449e83ecbd4ba48e7eaac5af26fea3b50f8f6072202c2dd7c5a6e7a6308f2421abe5e13a3bbd55221087f76320c5e09f25a8fdad1bab2b77c68ae74d92234ea5 + languageName: node + linkType: hard + "call-me-maybe@npm:^1.0.1": version: 1.0.2 resolution: "call-me-maybe@npm:1.0.2" @@ -23737,6 +24633,7 @@ __metadata: "@4tw/cypress-drag-drop": ^1.4.0 "@testing-library/cypress": ^10.0.0 "@webiny/project-utils": 0.0.0 + "@webiny/utils": 0.0.0 amazon-cognito-identity-js: ^4.5.3 cypress: ^13.0.0 cypress-image-snapshot: ^4.0.1 @@ -23747,6 +24644,7 @@ __metadata: lodash: ^4.17.11 nanoid: ^3.0.0 node-fetch: ^2.6.1 + typescript: 4.7.4 uniqid: ^5.2.0 languageName: unknown linkType: soft @@ -24246,6 +25144,32 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:^2.2.3": + version: 2.2.3 + resolution: "deep-equal@npm:2.2.3" + dependencies: + array-buffer-byte-length: ^1.0.0 + call-bind: ^1.0.5 + es-get-iterator: ^1.1.3 + get-intrinsic: ^1.2.2 + is-arguments: ^1.1.1 + is-array-buffer: ^3.0.2 + is-date-object: ^1.0.5 + is-regex: ^1.1.4 + is-shared-array-buffer: ^1.0.2 + isarray: ^2.0.5 + object-is: ^1.1.5 + object-keys: ^1.1.1 + object.assign: ^4.1.4 + regexp.prototype.flags: ^1.5.1 + side-channel: ^1.0.4 + which-boxed-primitive: ^1.0.2 + which-collection: ^1.0.1 + which-typed-array: ^1.1.13 + checksum: ee8852f23e4d20a5626c13b02f415ba443a1b30b4b3d39eaf366d59c4a85e6545d7ec917db44d476a85ae5a86064f7e5f7af7479f38f113995ba869f3a1ddc53 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -24309,6 +25233,17 @@ __metadata: languageName: node linkType: hard +"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.1": + version: 1.1.1 + resolution: "define-data-property@npm:1.1.1" + dependencies: + get-intrinsic: ^1.2.1 + gopd: ^1.0.1 + has-property-descriptors: ^1.0.0 + checksum: a29855ad3f0630ea82e3c5012c812efa6ca3078d5c2aa8df06b5f597c1cde6f7254692df41945851d903e05a1668607b6d34e778f402b9ff9ffb38111f1a3f0d + languageName: node + linkType: hard + "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -25659,7 +26594,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.2": +"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.2, es-get-iterator@npm:^1.1.3": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -27957,6 +28892,13 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 2b0ff4ce708d99715ad14a6d1f894e2a83242e4a52ccfcefaee5e40050562e5f6dafc1adbb4ce2d4ab47279a45dc736ab91ea5042d843c3c092820dfe032efb1 + languageName: node + linkType: hard + "function.prototype.name@npm:^1.1.0, function.prototype.name@npm:^1.1.5": version: 1.1.5 resolution: "function.prototype.name@npm:1.1.5" @@ -27976,7 +28918,7 @@ __metadata: languageName: node linkType: hard -"functions-have-names@npm:^1.2.2": +"functions-have-names@npm:^1.2.2, functions-have-names@npm:^1.2.3": version: 1.2.3 resolution: "functions-have-names@npm:1.2.3" checksum: c3f1f5ba20f4e962efb71344ce0a40722163e85bee2101ce25f88214e78182d2d2476aa85ef37950c579eb6cf6ee811c17b3101bb84004bb75655f3e33f3fdb5 @@ -28061,6 +29003,18 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2": + version: 1.2.2 + resolution: "get-intrinsic@npm:1.2.2" + dependencies: + function-bind: ^1.1.2 + has-proto: ^1.0.1 + has-symbols: ^1.0.3 + hasown: ^2.0.0 + checksum: 447ff0724df26829908dc033b62732359596fcf66027bc131ab37984afb33842d9cd458fd6cecadfe7eac22fd8a54b349799ed334cf2726025c921c7250e7417 + languageName: node + linkType: hard + "get-own-enumerable-property-symbols@npm:^3.0.0": version: 3.0.2 resolution: "get-own-enumerable-property-symbols@npm:3.0.2" @@ -29031,6 +29985,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.0": + version: 2.0.0 + resolution: "hasown@npm:2.0.0" + dependencies: + function-bind: ^1.1.2 + checksum: 6151c75ca12554565098641c98a40f4cc86b85b0fd5b6fe92360967e4605a4f9610f7757260b4e8098dd1c2ce7f4b095f2006fe72a570e3b6d2d28de0298c176 + languageName: node + linkType: hard + "hast-util-parse-selector@npm:^2.0.0": version: 2.2.5 resolution: "hast-util-parse-selector@npm:2.2.5" @@ -35454,6 +36417,18 @@ __metadata: languageName: node linkType: hard +"null-loader@npm:^4.0.1": + version: 4.0.1 + resolution: "null-loader@npm:4.0.1" + dependencies: + loader-utils: ^2.0.0 + schema-utils: ^3.0.0 + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + checksum: eeb4c4dd2f8f41e46f5665e4500359109e95ec1028a178a60e0161984906572da7dd87644bcc3cb29f0125d77e2b2508fb4f3813cfb1c6604a15865beb4b987b + languageName: node + linkType: hard + "num2fraction@npm:^1.2.2": version: 1.2.2 resolution: "num2fraction@npm:1.2.2" @@ -35521,6 +36496,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^3.0.0": + version: 3.0.0 + resolution: "object-hash@npm:3.0.0" + checksum: 80b4904bb3857c52cc1bfd0b52c0352532ca12ed3b8a6ff06a90cd209dfda1b95cee059a7625eb9da29537027f68ac4619363491eedb2f5d3dddbba97494fd6c + languageName: node + linkType: hard + "object-inspect@npm:^1.12.2, object-inspect@npm:^1.12.3, object-inspect@npm:^1.9.0": version: 1.12.3 resolution: "object-inspect@npm:1.12.3" @@ -40222,6 +41204,17 @@ __metadata: languageName: node linkType: hard +"regexp.prototype.flags@npm:^1.5.1": + version: 1.5.1 + resolution: "regexp.prototype.flags@npm:1.5.1" + dependencies: + call-bind: ^1.0.2 + define-properties: ^1.2.0 + set-function-name: ^2.0.0 + checksum: 869edff00288442f8d7fa4c9327f91d85f3b3acf8cbbef9ea7a220345cf23e9241b6def9263d2c1ebcf3a316b0aa52ad26a43a84aa02baca3381717b3e307f47 + languageName: node + linkType: hard + "regexpp@npm:^3.0.0, regexpp@npm:^3.2.0": version: 3.2.0 resolution: "regexpp@npm:3.2.0" @@ -40906,6 +41899,7 @@ __metadata: rimraf: ^3.0.2 rxjs: ^6.5.5 semver: ^7.5.4 + ts-expect: ^1.3.0 ts-jest: ^29.1.0 typescript: 4.7.4 typescript-transform-paths: ^2.2.3 @@ -41485,6 +42479,29 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.1.1": + version: 1.1.1 + resolution: "set-function-length@npm:1.1.1" + dependencies: + define-data-property: ^1.1.1 + get-intrinsic: ^1.2.1 + gopd: ^1.0.1 + has-property-descriptors: ^1.0.0 + checksum: c131d7569cd7e110cafdfbfbb0557249b538477624dfac4fc18c376d879672fa52563b74029ca01f8f4583a8acb35bb1e873d573a24edb80d978a7ee607c6e06 + languageName: node + linkType: hard + +"set-function-name@npm:^2.0.0": + version: 2.0.1 + resolution: "set-function-name@npm:2.0.1" + dependencies: + define-data-property: ^1.0.1 + functions-have-names: ^1.2.3 + has-property-descriptors: ^1.0.0 + checksum: 4975d17d90c40168eee2c7c9c59d023429f0a1690a89d75656306481ece0c3c1fb1ebcc0150ea546d1913e35fbd037bace91372c69e543e51fc5d1f31a9fa126 + languageName: node + linkType: hard + "set-value@npm:^2.0.0, set-value@npm:^2.0.1": version: 2.0.1 resolution: "set-value@npm:2.0.1" @@ -41572,7 +42589,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:*": +"sharp@npm:*, sharp@npm:0.32.6": version: 0.32.6 resolution: "sharp@npm:0.32.6" dependencies: @@ -43873,6 +44890,13 @@ __metadata: languageName: node linkType: hard +"ts-expect@npm:^1.3.0": + version: 1.3.0 + resolution: "ts-expect@npm:1.3.0" + checksum: c007702906354ada640392c26373660a8a034506859b5c084de74376ca2c6fe092c5909235d1bad8ed67423e7ab023dae4dc3032dcdcce70852c0211c5238013 + languageName: node + linkType: hard + "ts-invariant@npm:^0.4.0, ts-invariant@npm:^0.4.4": version: 0.4.4 resolution: "ts-invariant@npm:0.4.4" @@ -44189,6 +45213,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^2.19.0": + version: 2.19.0 + resolution: "type-fest@npm:2.19.0" + checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 + languageName: node + linkType: hard + "type-fest@npm:^3.10.0": version: 3.10.0 resolution: "type-fest@npm:3.10.0" @@ -45610,6 +46641,19 @@ __metadata: languageName: node linkType: hard +"which-typed-array@npm:^1.1.13": + version: 1.1.13 + resolution: "which-typed-array@npm:1.1.13" + dependencies: + available-typed-arrays: ^1.0.5 + call-bind: ^1.0.4 + for-each: ^0.3.3 + gopd: ^1.0.1 + has-tostringtag: ^1.0.0 + checksum: 3828a0d5d72c800e369d447e54c7620742a4cc0c9baf1b5e8c17e9b6ff90d8d861a3a6dd4800f1953dbf80e5e5cec954a289e5b4a223e3bee4aeb1f8c5f33309 + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.2, which-typed-array@npm:^1.1.9": version: 1.1.9 resolution: "which-typed-array@npm:1.1.9"