From 20bf08f9496abc32b6bb48ceefef9a5b57eea2ab Mon Sep 17 00:00:00 2001 From: Leonardo Giacone Date: Thu, 15 Aug 2024 16:11:49 +0200 Subject: [PATCH] feat(api-headless-cms-tasks): HCMS bulk actions via background tasks (#4112) --- .github/workflows/pullRequests.yml | 16 +- .github/workflows/pushDev.yml | 16 +- .github/workflows/pushNext.yml | 16 +- .../wac/utils/listPackagesWithJestTests.ts | 14 +- apps/api/graphql/package.json | 2 +- apps/api/graphql/src/index.ts | 4 +- apps/api/graphql/src/types.ts | 4 +- apps/api/graphql/tsconfig.json | 10 +- .../.babelrc.js | 0 .../LICENSE | 0 .../api-headless-cms-bulk-actions/README.md | 15 + .../__tests__/context/helpers.ts | 4 +- .../__tests__/context/tenancySecurity.ts | 6 +- .../__tests__/context/useGraphQLHandler.ts | 120 ++++++++ .../__tests__/mocks/index.ts | 0 .../__tests__/mocks/models.ts | 46 ++- .../__tests__/tasks/createBulkAction.test.ts | 122 ++++++++ .../jest.setup.js | 0 .../package.json | 11 +- .../src/abstractions/IListEntries.ts | 8 + .../src/abstractions/IProcessEntry.ts | 5 + .../src/abstractions}/IUseCase.ts | 0 .../src/abstractions/index.ts | 3 + .../src/handlers/eventBridgeEventHandler.ts | 6 +- .../src/handlers/index.ts | 0 .../src/handlers/setupEventsTenant.ts | 0 .../src/index.ts | 13 + .../src/plugins/createBulkAction.ts | 41 +++ .../src/plugins/createBulkActionGraphQL.ts | 65 +++++ .../src/plugins/createBulkActionTasks.ts | 136 +++++++++ .../src/plugins/createDefaultGraphQL.ts | 56 ++++ .../src/plugins/index.ts | 2 + .../src/tasks/createBulkActionEntriesTasks.ts | 47 +++ .../src/tasks/createEmptyTrashBinsTask.ts | 82 ++++++ .../src/tasks/index.ts | 6 + .../src/types.ts | 61 ++++ .../src/useCases/DeleteEntry.ts | 23 ++ .../src/useCases/ListDeletedEntries.ts | 30 ++ .../src/useCases/ListLatestEntries.ts | 30 ++ .../src/useCases/ListPublishedEntries.ts | 30 ++ .../src/useCases/MoveEntryToFolder.ts | 26 ++ .../src/useCases/MoveEntryToTrash.ts | 21 ++ .../src/useCases/PublishEntry.ts | 19 ++ .../src/useCases/RestoreEntryFromTrash.ts | 21 ++ .../src/useCases/UnpublishEntry.ts | 19 ++ .../src/useCases/index.ts | 9 + .../ChildTaskCleanup}/ChildTasksCleanup.ts | 2 +- .../internals/ChildTaskCleanup}/index.ts | 0 .../CreateTasksByModel/CreateTasksByModel.ts | 109 +++++++ .../internals/CreateTasksByModel/TaskCache.ts | 66 +++++ .../internals/CreateTasksByModel/index.ts | 1 + .../internals/ProcessTask/ProcessTask.ts | 87 ++++++ .../useCases/internals/ProcessTask/Result.ts} | 2 +- .../useCases/internals/ProcessTask/index.ts | 1 + .../ProcessTasksByModel.ts | 60 ++++ .../internals/ProcessTasksByModel/index.ts | 1 + .../src/useCases/internals/index.ts | 4 + .../tsconfig.build.json | 7 +- .../tsconfig.json | 27 +- .../webiny.config.js | 0 packages/api-headless-cms-tasks/README.md | 15 - .../__tests__/context/useHandler.ts | 58 ---- .../tasks/deleteTrashBinEntries.test.ts | 179 ------------ .../tasks/emptyTrashBinByModel.test.ts | 272 ------------------ .../__tests__/tasks/emptyTrashBins.test.ts | 118 -------- .../api-headless-cms-tasks/src/graphql.ts | 49 ---- packages/api-headless-cms-tasks/src/index.ts | 6 - .../src/tasks/common/index.ts | 1 - .../src/tasks/common/useCases/index.ts | 1 - .../entries/deleteTrashBinEntriesTask.ts | 55 ---- .../tasks/entries/emptyTrashBinByModelTask.ts | 55 ---- .../src/tasks/entries/emptyTrashBinsTask.ts | 45 --- .../src/tasks/entries/index.ts | 13 - .../DeleteTrashBinEntries.ts | 70 ----- .../TaskRepositoryFactory.ts | 21 -- .../useCases/DeleteTrashBinEntries/index.ts | 1 - .../CreateDeleteEntriesTasks.ts | 104 ------- .../EmptyTrashBinByModel.ts | 25 -- .../ProcessDeleteEntriesTasks.ts | 46 --- .../EmptyTrashBinByModel/TaskCache.ts | 20 -- .../EmptyTrashBinByModel/TaskTrigger.ts | 38 --- .../useCases/EmptyTrashBinByModel/index.ts | 1 - .../useCases/EmptyTrashBins/EmptyTrashBins.ts | 59 ---- .../entries/useCases/EmptyTrashBins/index.ts | 1 - .../src/tasks/entries/useCases/index.ts | 3 - .../api-headless-cms-tasks/src/tasks/index.ts | 5 - packages/api-headless-cms-tasks/src/types.ts | 67 ----- packages/app-aco/src/contexts/acoList.tsx | 156 +++++++++- .../styles/material-theme-assignments.scss | 6 + .../src/entries.graphql.ts | 36 +++ .../BulkActions/ActionDelete.tsx | 21 +- .../ContentEntries/BulkActions/ActionMove.tsx | 25 +- .../BulkActions/ActionPublish.tsx | 21 +- .../BulkActions/ActionUnpublish.tsx | 21 +- .../BulkActions/BulkActions.tsx | 18 +- .../ContentEntries/SelectAll/Messages.tsx | 27 ++ .../SelectAll/SelectAll.styled.tsx | 21 ++ .../ContentEntries/SelectAll/SelectAll.tsx | 26 ++ .../ContentEntries/SelectAll/index.ts | 1 + .../TrashBinBulkActionsGraphQLGateway.ts | 43 +++ .../ContentEntries/TrashBin/adapters/index.ts | 1 + .../TrashBin/components/TrashBin.tsx | 8 + .../list/Browser/BulkAction.tsx | 12 +- .../src/admin/contexts/Cms/index.tsx | 62 +++- .../admin/views/contentEntries/Table/Main.tsx | 2 + .../hooks/useContentEntriesList.tsx | 21 +- .../SelectedItems/ISelectedItemsRepository.ts | 3 + .../SelectedItems/SelectedItemsRepository.ts | 15 + .../TrashBinItems/ITrashBinItemsRepository.ts | 3 +- .../TrashBinItems/TrashBinItemsRepository.ts | 13 +- .../TrashBinItemsRepositoryFactory.ts | 3 + .../TrashBinItemsRepositoryWithLoading.ts | 6 +- .../ITrashBinBulkActionsGateway.ts | 5 + .../src/Gateways/TrashBinBulkActions/index.ts | 1 + packages/app-trash-bin/src/Gateways/index.ts | 1 + .../Presentation/TrashBin/TrashBin.test.ts | 177 +++++++++++- .../src/Presentation/TrashBin/TrashBin.tsx | 9 +- .../TrashBin/TrashBinControllers.ts | 41 ++- .../TrashBin/TrashBinPresenter.ts | 11 + .../BulkAction/BulkActionsController.ts | 18 ++ .../BulkAction/IBulkActionsController.ts | 5 + .../TrashBin/controllers/BulkAction/index.ts | 2 + .../ISelectAllItemsController.ts | 3 + .../SelectAllItemsController.ts | 15 + .../controllers/SelectAllItems/index.ts | 2 + .../IUnselectAllItemsController.ts | 3 + .../UnselectAllItemsController.ts | 15 + .../controllers/UnselectAllItems/index.ts | 2 + .../TrashBin/controllers/index.ts | 3 + .../abstractions/ITrashBinControllers.ts | 9 +- .../abstractions/ITrashBinPresenter.ts | 2 + .../BulkActions/BulkActions/BulkActions.tsx | 14 +- .../BulkActions/DeleteItems/DeleteItems.tsx | 21 +- .../BulkActions/RestoreItems/RestoreItems.tsx | 22 +- .../components/SelectAll/Messages.tsx | 27 ++ .../components/SelectAll/SelectAll.styled.tsx | 21 ++ .../components/SelectAll/SelectAll.tsx | 29 ++ .../components/SelectAll/index.ts | 1 + .../TrashBinOverlay/TrashBinOverlay.tsx | 2 + .../configs/list/Browser/BulkAction.tsx | 10 +- .../src/Presentation/hooks/useTrashBin.tsx | 28 +- .../app-trash-bin/src/Presentation/index.tsx | 6 +- .../UseCases/BulkAction/BulkActionUseCase.ts | 17 ++ .../UseCases/BulkAction/IBulkActionUseCase.ts | 5 + .../src/UseCases/BulkAction/index.ts | 2 + .../SelectAllItems/ISelectAllItemsUseCase.ts | 3 + .../SelectAllItems/SelectAllItemsUseCase.ts | 16 ++ .../src/UseCases/SelectAllItems/index.ts | 2 + .../IUnselectAllItemsUseCase.ts | 3 + .../UnselectAllItemsUseCase.ts | 16 ++ .../src/UseCases/UnSelectAllItems/index.ts | 2 + packages/app-trash-bin/src/UseCases/index.ts | 3 + packages/app-trash-bin/src/types.ts | 18 ++ .../ddb-es/apps/api/graphql/package.json | 2 +- .../ddb-es/apps/api/graphql/src/index.ts | 4 +- .../ddb-es/apps/api/graphql/src/types.ts | 4 +- .../ddb-os/apps/api/graphql/package.json | 2 +- .../ddb-os/apps/api/graphql/src/index.ts | 4 +- .../ddb-os/apps/api/graphql/src/types.ts | 4 +- .../ddb/apps/api/graphql/package.json | 2 +- .../ddb/apps/api/graphql/src/index.ts | 4 +- .../ddb/apps/api/graphql/src/types.ts | 4 +- scripts/listPackagesWithTests.js | 6 +- yarn.lock | 63 ++-- 164 files changed, 2604 insertions(+), 1518 deletions(-) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/.babelrc.js (100%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/LICENSE (100%) create mode 100644 packages/api-headless-cms-bulk-actions/README.md rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/__tests__/context/helpers.ts (92%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/__tests__/context/tenancySecurity.ts (92%) create mode 100644 packages/api-headless-cms-bulk-actions/__tests__/context/useGraphQLHandler.ts rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/__tests__/mocks/index.ts (100%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/__tests__/mocks/models.ts (51%) create mode 100644 packages/api-headless-cms-bulk-actions/__tests__/tasks/createBulkAction.test.ts rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/jest.setup.js (100%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/package.json (81%) create mode 100644 packages/api-headless-cms-bulk-actions/src/abstractions/IListEntries.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/abstractions/IProcessEntry.ts rename packages/{api-headless-cms-tasks/src/tasks => api-headless-cms-bulk-actions/src/abstractions}/IUseCase.ts (100%) create mode 100644 packages/api-headless-cms-bulk-actions/src/abstractions/index.ts rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/src/handlers/eventBridgeEventHandler.ts (89%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/src/handlers/index.ts (100%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/src/handlers/setupEventsTenant.ts (100%) create mode 100644 packages/api-headless-cms-bulk-actions/src/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/plugins/createBulkAction.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionGraphQL.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionTasks.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/plugins/createDefaultGraphQL.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/plugins/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/tasks/createBulkActionEntriesTasks.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/tasks/createEmptyTrashBinsTask.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/tasks/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/types.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/DeleteEntry.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/ListDeletedEntries.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/ListLatestEntries.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/ListPublishedEntries.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToFolder.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToTrash.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/PublishEntry.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/RestoreEntryFromTrash.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/UnpublishEntry.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/index.ts rename packages/{api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup => api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup}/ChildTasksCleanup.ts (97%) rename packages/{api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup => api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup}/index.ts (100%) create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/CreateTasksByModel.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/TaskCache.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/ProcessTask.ts rename packages/{api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepository.ts => api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/Result.ts} (94%) create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/ProcessTasksByModel.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/index.ts create mode 100644 packages/api-headless-cms-bulk-actions/src/useCases/internals/index.ts rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/tsconfig.build.json (95%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/tsconfig.json (94%) rename packages/{api-headless-cms-tasks => api-headless-cms-bulk-actions}/webiny.config.js (100%) delete mode 100644 packages/api-headless-cms-tasks/README.md delete mode 100644 packages/api-headless-cms-tasks/__tests__/context/useHandler.ts delete mode 100644 packages/api-headless-cms-tasks/__tests__/tasks/deleteTrashBinEntries.test.ts delete mode 100644 packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBinByModel.test.ts delete mode 100644 packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBins.test.ts delete mode 100644 packages/api-headless-cms-tasks/src/graphql.ts delete mode 100644 packages/api-headless-cms-tasks/src/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/common/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/common/useCases/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/deleteTrashBinEntriesTask.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinByModelTask.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinsTask.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/DeleteTrashBinEntries.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepositoryFactory.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/CreateDeleteEntriesTasks.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/EmptyTrashBinByModel.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/ProcessDeleteEntriesTasks.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskCache.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskTrigger.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/EmptyTrashBins.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/entries/useCases/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/tasks/index.ts delete mode 100644 packages/api-headless-cms-tasks/src/types.ts create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/Messages.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.styled.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.tsx create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/index.ts create mode 100644 packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/TrashBinBulkActionsGraphQLGateway.ts create mode 100644 packages/app-trash-bin/src/Gateways/TrashBinBulkActions/ITrashBinBulkActionsGateway.ts create mode 100644 packages/app-trash-bin/src/Gateways/TrashBinBulkActions/index.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/BulkActionsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/IBulkActionsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/index.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/ISelectAllItemsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/SelectAllItemsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/index.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/IUnselectAllItemsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/UnselectAllItemsController.ts create mode 100644 packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/index.ts create mode 100644 packages/app-trash-bin/src/Presentation/components/SelectAll/Messages.tsx create mode 100644 packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.styled.tsx create mode 100644 packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.tsx create mode 100644 packages/app-trash-bin/src/Presentation/components/SelectAll/index.ts create mode 100644 packages/app-trash-bin/src/UseCases/BulkAction/BulkActionUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/BulkAction/IBulkActionUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/BulkAction/index.ts create mode 100644 packages/app-trash-bin/src/UseCases/SelectAllItems/ISelectAllItemsUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/SelectAllItems/SelectAllItemsUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/SelectAllItems/index.ts create mode 100644 packages/app-trash-bin/src/UseCases/UnSelectAllItems/IUnselectAllItemsUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/UnSelectAllItems/UnselectAllItemsUseCase.ts create mode 100644 packages/app-trash-bin/src/UseCases/UnSelectAllItems/index.ts diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index f7af5259988..7dcc25e46c2 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -227,8 +227,8 @@ jobs: --storage=ddb","storage":"ddb","id":"api-file-manager_ddb"},{"cmd":"packages/api-form-builder --storage=ddb","storage":"ddb","id":"api-form-builder_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb","storage":"ddb","id":"api-headless-cms_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb","storage":"ddb","id":"api-headless-cms-tasks_ddb"},{"cmd":"packages/api-i18n + --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb","storage":"ddb","id":"api-headless-cms-bulk-actions_ddb"},{"cmd":"packages/api-i18n --storage=ddb","storage":"ddb","id":"api-i18n_ddb"},{"cmd":"packages/api-mailer --storage=ddb","storage":"ddb","id":"api-mailer_ddb"},{"cmd":"packages/api-page-builder --storage=ddb","storage":"ddb","id":"api-page-builder_ddb"},{"cmd":"packages/api-page-builder-aco @@ -290,10 +290,10 @@ jobs: --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder_ddb-es_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder-so-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-bulk-actions_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer --storage=ddb-es,ddb","storage":"ddb-es","id":"api-mailer_ddb-es_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder_ddb-es_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder-aco_ddb-es_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es @@ -361,10 +361,10 @@ jobs: --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder_ddb-os_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder-so-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-bulk-actions_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer --storage=ddb-os,ddb","storage":"ddb-os","id":"api-mailer_ddb-os_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder_ddb-os_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder-aco_ddb-os_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es diff --git a/.github/workflows/pushDev.yml b/.github/workflows/pushDev.yml index 0bea44b5c2f..da90ff9d2fa 100644 --- a/.github/workflows/pushDev.yml +++ b/.github/workflows/pushDev.yml @@ -193,8 +193,8 @@ jobs: --storage=ddb","storage":"ddb","id":"api-file-manager_ddb"},{"cmd":"packages/api-form-builder --storage=ddb","storage":"ddb","id":"api-form-builder_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb","storage":"ddb","id":"api-headless-cms_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb","storage":"ddb","id":"api-headless-cms-tasks_ddb"},{"cmd":"packages/api-i18n + --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb","storage":"ddb","id":"api-headless-cms-bulk-actions_ddb"},{"cmd":"packages/api-i18n --storage=ddb","storage":"ddb","id":"api-i18n_ddb"},{"cmd":"packages/api-mailer --storage=ddb","storage":"ddb","id":"api-mailer_ddb"},{"cmd":"packages/api-page-builder --storage=ddb","storage":"ddb","id":"api-page-builder_ddb"},{"cmd":"packages/api-page-builder-aco @@ -256,10 +256,10 @@ jobs: --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder_ddb-es_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder-so-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-bulk-actions_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer --storage=ddb-es,ddb","storage":"ddb-es","id":"api-mailer_ddb-es_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder_ddb-es_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder-aco_ddb-es_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es @@ -326,10 +326,10 @@ jobs: --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder_ddb-os_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder-so-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-bulk-actions_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer --storage=ddb-os,ddb","storage":"ddb-os","id":"api-mailer_ddb-os_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder_ddb-os_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder-aco_ddb-os_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es diff --git a/.github/workflows/pushNext.yml b/.github/workflows/pushNext.yml index 81164fc8ce3..22d323a27b6 100644 --- a/.github/workflows/pushNext.yml +++ b/.github/workflows/pushNext.yml @@ -193,8 +193,8 @@ jobs: --storage=ddb","storage":"ddb","id":"api-file-manager_ddb"},{"cmd":"packages/api-form-builder --storage=ddb","storage":"ddb","id":"api-form-builder_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb","storage":"ddb","id":"api-headless-cms_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb","storage":"ddb","id":"api-headless-cms-tasks_ddb"},{"cmd":"packages/api-i18n + --storage=ddb","storage":"ddb","id":"api-headless-cms-aco_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb","storage":"ddb","id":"api-headless-cms-bulk-actions_ddb"},{"cmd":"packages/api-i18n --storage=ddb","storage":"ddb","id":"api-i18n_ddb"},{"cmd":"packages/api-mailer --storage=ddb","storage":"ddb","id":"api-mailer_ddb"},{"cmd":"packages/api-page-builder --storage=ddb","storage":"ddb","id":"api-page-builder_ddb"},{"cmd":"packages/api-page-builder-aco @@ -256,10 +256,10 @@ jobs: --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder_ddb-es_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-form-builder-so-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-aco_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-bulk-actions_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-ddb-es_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-es,ddb","storage":"ddb-es","id":"api-headless-cms-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-es,ddb","storage":["ddb-es"],"id":"api-headless-cms-es-tasks_ddb-es_ddb"},{"cmd":"packages/api-mailer --storage=ddb-es,ddb","storage":"ddb-es","id":"api-mailer_ddb-es_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder_ddb-es_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-es,ddb","storage":"ddb-es","id":"api-page-builder-aco_ddb-es_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es @@ -326,10 +326,10 @@ jobs: --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder_ddb-os_ddb"},{"cmd":"packages/api-form-builder-so-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-form-builder-so-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-aco - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-aco_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-bulk-actions_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-ddb-es --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-ddb-es_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-es-tasks - --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-headless-cms-tasks - --storage=ddb-os,ddb","storage":"ddb-os","id":"api-headless-cms-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer + --storage=ddb-os,ddb","storage":["ddb-os"],"id":"api-headless-cms-es-tasks_ddb-os_ddb"},{"cmd":"packages/api-mailer --storage=ddb-os,ddb","storage":"ddb-os","id":"api-mailer_ddb-os_ddb"},{"cmd":"packages/api-page-builder --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder_ddb-os_ddb"},{"cmd":"packages/api-page-builder-aco --storage=ddb-os,ddb","storage":"ddb-os","id":"api-page-builder-aco_ddb-os_ddb"},{"cmd":"packages/api-page-builder-so-ddb-es diff --git a/.github/workflows/wac/utils/listPackagesWithJestTests.ts b/.github/workflows/wac/utils/listPackagesWithJestTests.ts index f185875d917..7c9111e269b 100644 --- a/.github/workflows/wac/utils/listPackagesWithJestTests.ts +++ b/.github/workflows/wac/utils/listPackagesWithJestTests.ts @@ -149,11 +149,17 @@ const CUSTOM_HANDLERS: Record Array> = { { cmd: "packages/api-headless-cms-aco --storage=ddb-os,ddb", storage: "ddb-os" } ]; }, - "api-headless-cms-tasks": () => { + "api-headless-cms-bulk-actions": () => { return [ - { cmd: "packages/api-headless-cms-tasks --storage=ddb", storage: "ddb" }, - { cmd: "packages/api-headless-cms-tasks --storage=ddb-es,ddb", storage: "ddb-es" }, - { cmd: "packages/api-headless-cms-tasks --storage=ddb-os,ddb", storage: "ddb-os" } + { cmd: "packages/api-headless-cms-bulk-actions --storage=ddb", storage: "ddb" }, + { + cmd: "packages/api-headless-cms-bulk-actions --storage=ddb-es,ddb", + storage: "ddb-es" + }, + { + cmd: "packages/api-headless-cms-bulk-actions --storage=ddb-os,ddb", + storage: "ddb-os" + } ]; }, "api-apw": () => { diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index b0a9ffc10ee..8258f780c3f 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -20,8 +20,8 @@ "@webiny/api-form-builder-so-ddb": "0.0.0", "@webiny/api-headless-cms": "0.0.0", "@webiny/api-headless-cms-aco": "0.0.0", + "@webiny/api-headless-cms-bulk-actions": "0.0.0", "@webiny/api-headless-cms-ddb": "0.0.0", - "@webiny/api-headless-cms-tasks": "0.0.0", "@webiny/api-i18n": "0.0.0", "@webiny/api-i18n-content": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index a957791fc2b..85696fcd81e 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -28,7 +28,7 @@ import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { createAcoHcmsContext } from "@webiny/api-headless-cms-aco"; -import { createHcmsTasks } from "@webiny/api-headless-cms-tasks"; +import { createHcmsBulkActions } from "@webiny/api-headless-cms-bulk-actions"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import securityPlugins from "./security"; import tenantManager from "@webiny/api-tenant-manager"; @@ -108,7 +108,7 @@ export const handler = createHandler({ createAco(), createAcoPageBuilderContext(), createAcoHcmsContext(), - createHcmsTasks(), + createHcmsBulkActions(), createFileModelModifier(({ modifier }) => { modifier.addField({ id: "customField1", diff --git a/apps/api/graphql/src/types.ts b/apps/api/graphql/src/types.ts index 73ccf9a4286..8cffccea10a 100644 --- a/apps/api/graphql/src/types.ts +++ b/apps/api/graphql/src/types.ts @@ -9,7 +9,7 @@ import { AcoContext } from "@webiny/api-aco/types"; import { PbAcoContext } from "@webiny/api-page-builder-aco/types"; import { Context as TasksContext } from "@webiny/tasks/types"; import { HcmsAcoContext } from "@webiny/api-headless-cms-aco/types"; -import { HcmsTasksContext } from "@webiny/api-headless-cms-tasks/types"; +import { HcmsBulkActionsContext } from "@webiny/api-headless-cms-bulk-actions/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 @@ -29,4 +29,4 @@ export interface Context TasksContext, PbAcoContext, HcmsAcoContext, - HcmsTasksContext {} + HcmsBulkActionsContext {} diff --git a/apps/api/graphql/tsconfig.json b/apps/api/graphql/tsconfig.json index 422598f9954..799374664d3 100644 --- a/apps/api/graphql/tsconfig.json +++ b/apps/api/graphql/tsconfig.json @@ -45,7 +45,7 @@ "path": "../../../packages/api-headless-cms-aco/tsconfig.build.json" }, { - "path": "../../../packages/api-headless-cms-tasks/tsconfig.build.json" + "path": "../../../packages/api-headless-cms-bulk-actions/tsconfig.build.json" }, { "path": "../../../packages/api-headless-cms-ddb/tsconfig.build.json" @@ -154,12 +154,16 @@ "@webiny/api-headless-cms": ["../../../packages/api-headless-cms/src"], "@webiny/api-headless-cms-aco/*": ["../../../packages/api-headless-cms-aco/src/*"], "@webiny/api-headless-cms-aco": ["../../../packages/api-headless-cms-aco/src"], + "@webiny/api-headless-cms-bulk-actions/*": [ + "../../../packages/api-headless-cms-bulk-actions/src/*" + ], + "@webiny/api-headless-cms-bulk-actions": [ + "../../../packages/api-headless-cms-bulk-actions/src" + ], "@webiny/api-headless-cms-ddb/*": ["../../../packages/api-headless-cms-ddb/src/*"], "@webiny/api-headless-cms-ddb": ["../../../packages/api-headless-cms-ddb/src"], "@webiny/api-record-locking/*": ["../../../packages/api-record-locking/src/*"], "@webiny/api-record-locking": ["../../../packages/api-record-locking/src"], - "@webiny/api-headless-cms-tasks/*": ["../../../packages/api-headless-cms-tasks/src/*"], - "@webiny/api-headless-cms-tasks": ["../../../packages/api-headless-cms-tasks/src"], "@webiny/api-i18n/*": ["../../../packages/api-i18n/src/*"], "@webiny/api-i18n": ["../../../packages/api-i18n/src"], "@webiny/api-i18n-content/*": ["../../../packages/api-i18n-content/src/*"], diff --git a/packages/api-headless-cms-tasks/.babelrc.js b/packages/api-headless-cms-bulk-actions/.babelrc.js similarity index 100% rename from packages/api-headless-cms-tasks/.babelrc.js rename to packages/api-headless-cms-bulk-actions/.babelrc.js diff --git a/packages/api-headless-cms-tasks/LICENSE b/packages/api-headless-cms-bulk-actions/LICENSE similarity index 100% rename from packages/api-headless-cms-tasks/LICENSE rename to packages/api-headless-cms-bulk-actions/LICENSE diff --git a/packages/api-headless-cms-bulk-actions/README.md b/packages/api-headless-cms-bulk-actions/README.md new file mode 100644 index 00000000000..9ebbfa293b8 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/README.md @@ -0,0 +1,15 @@ +# @webiny/api-headless-cms-bulk-actions +[![](https://img.shields.io/npm/dw/@webiny/api-headless-cms-bulk-actions.svg)](https://www.npmjs.com/package/@webinyapi-headless-cms-bulk-actions) +[![](https://img.shields.io/npm/v/@webiny/api-headless-cms-bulk-actions.svg)](https://www.npmjs.com/package/@webiny/api-headless-cms-bulk-actions) +[![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 +``` +npm install --save @webiny/api-headless-cms-bulk-actions +``` + +Or if you prefer yarn: +``` +yarn add @webiny/api-headless-cms-bulk-actions +``` diff --git a/packages/api-headless-cms-tasks/__tests__/context/helpers.ts b/packages/api-headless-cms-bulk-actions/__tests__/context/helpers.ts similarity index 92% rename from packages/api-headless-cms-tasks/__tests__/context/helpers.ts rename to packages/api-headless-cms-bulk-actions/__tests__/context/helpers.ts index d76c1a05375..f74ed2b96c0 100644 --- a/packages/api-headless-cms-tasks/__tests__/context/helpers.ts +++ b/packages/api-headless-cms-bulk-actions/__tests__/context/helpers.ts @@ -1,6 +1,6 @@ import { SecurityIdentity } from "@webiny/api-security/types"; import { ContextPlugin } from "@webiny/api"; -import { HcmsTasksContext } from "~/types"; +import { HcmsBulkActionsContext } from "~/types"; export interface PermissionsArg { name: string; @@ -47,7 +47,7 @@ export const createIdentity = (identity?: SecurityIdentity) => { }; export const createDummyLocales = () => { - return new ContextPlugin(async context => { + return new ContextPlugin(async context => { const { i18n, security } = context; await security.authenticate(""); diff --git a/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts b/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts similarity index 92% rename from packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts rename to packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts index ae41b450c37..b3a5baf1263 100644 --- a/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts +++ b/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts @@ -8,7 +8,7 @@ import { } from "@webiny/api-security/types"; import { ContextPlugin } from "@webiny/api"; import { BeforeHandlerPlugin } from "@webiny/handler"; -import { HcmsTasksContext } from "~/types"; +import { HcmsBulkActionsContext } from "~/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; @@ -37,7 +37,7 @@ export const createTenancyAndSecurity = ({ setupGraphQL ? createTenancyGraphQL() : null, createSecurityContext({ storageOperations: securityStorage.storageOperations }), setupGraphQL ? createSecurityGraphQL() : null, - new ContextPlugin(context => { + new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", name: "Root", @@ -57,7 +57,7 @@ export const createTenancyAndSecurity = ({ return permissions || [{ name: "*" }]; }); }), - new BeforeHandlerPlugin(context => { + new BeforeHandlerPlugin(context => { const { headers = {} } = context.request || {}; if (headers["authorization"]) { return context.security.authenticate(headers["authorization"]); diff --git a/packages/api-headless-cms-bulk-actions/__tests__/context/useGraphQLHandler.ts b/packages/api-headless-cms-bulk-actions/__tests__/context/useGraphQLHandler.ts new file mode 100644 index 00000000000..fdcc8d02569 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/__tests__/context/useGraphQLHandler.ts @@ -0,0 +1,120 @@ +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import { createHandler } from "@webiny/handler-aws"; +import createGraphQLHandler from "@webiny/handler-graphql"; +import { Plugin, PluginCollection } from "@webiny/plugins/types"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { until } from "@webiny/project-utils/testing/helpers/until"; +import { getIntrospectionQuery } from "graphql"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { createDummyLocales, createIdentity, createPermissions } from "~tests/context/helpers"; +import { createBackgroundTaskContext, createBackgroundTaskGraphQL } from "@webiny/tasks"; +import { createHcmsBulkActions } from "~/index"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createI18NContext } from "@webiny/api-i18n"; +import { SecurityIdentity, SecurityPermission } from "@webiny/api-security/types"; +import { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; +import { AdminUsersStorageOperations } from "@webiny/api-admin-users/types"; +import createAdminUsersApp from "@webiny/api-admin-users"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; + +export interface UseGQLHandlerParams { + permissions?: SecurityPermission[]; + identity?: SecurityIdentity; + plugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + storageOperationPlugins?: any[]; + testProjectLicense?: DecryptedWcpProjectLicense; +} + +interface InvokeParams { + httpMethod?: "POST"; + type?: string; + locale?: string; + body: { + query: string; + variables?: Record; + }; + headers?: Record; +} + +export const useGraphQlHandler = (params: UseGQLHandlerParams = {}) => { + const { plugins = [] } = params; + + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + const adminUsersStorage = getStorageOps("adminUsers"); + + const handler = createHandler({ + plugins: [ + createWcpContext(), + ...cmsStorage.plugins, + createGraphQLHandler(), + ...createTenancyAndSecurity({ + setupGraphQL: true, + permissions: createPermissions(), + identity: createIdentity() + }), + createI18NContext(), + ...i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + createAdminUsersApp({ + storageOperations: adminUsersStorage.storageOperations + }), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + graphQLHandlerPlugins(), + createBackgroundTaskContext(), + createBackgroundTaskGraphQL(), + createHcmsBulkActions(), + plugins + ], + debug: true + }); + + const invoke = async ({ + httpMethod = "POST", + type = "manage", + locale = "en-US", + body, + headers = {}, + ...rest + }: InvokeParams) => { + const response = await handler( + { + path: `/cms/${type}/${locale}`, + httpMethod, + headers: { + ["x-tenant"]: "root", + ["Content-Type"]: "application/json", + ...headers + }, + body: JSON.stringify(body), + ...rest + } as unknown as APIGatewayEvent, + {} as LambdaContext + ); + // The first element is the response body, and the second is the raw response. + return [JSON.parse(response.body), response]; + }; + + const introspect = async () => { + return invoke({ + body: { + query: getIntrospectionQuery() + } + }); + }; + + return { + params, + until, + handler, + invoke, + introspect + }; +}; diff --git a/packages/api-headless-cms-tasks/__tests__/mocks/index.ts b/packages/api-headless-cms-bulk-actions/__tests__/mocks/index.ts similarity index 100% rename from packages/api-headless-cms-tasks/__tests__/mocks/index.ts rename to packages/api-headless-cms-bulk-actions/__tests__/mocks/index.ts diff --git a/packages/api-headless-cms-tasks/__tests__/mocks/models.ts b/packages/api-headless-cms-bulk-actions/__tests__/mocks/models.ts similarity index 51% rename from packages/api-headless-cms-tasks/__tests__/mocks/models.ts rename to packages/api-headless-cms-bulk-actions/__tests__/mocks/models.ts index 99072e29872..f9bd33e2134 100644 --- a/packages/api-headless-cms-tasks/__tests__/mocks/models.ts +++ b/packages/api-headless-cms-bulk-actions/__tests__/mocks/models.ts @@ -1,7 +1,7 @@ import { CmsGroup, - createCmsGroup, - createCmsModel, + createCmsGroupPlugin, + createCmsModelPlugin, createPrivateModel } from "@webiny/api-headless-cms"; @@ -14,8 +14,8 @@ export const createMockModels = () => { description: "Mock Group Description" }; return [ - createCmsGroup(group), - createCmsModel({ + createCmsGroupPlugin(group), + createCmsModelPlugin({ noValidate: true, modelId: "car", singularApiName: "Car", @@ -23,11 +23,19 @@ export const createMockModels = () => { group: group, name: "Car", description: "Car model", - fields: [], - layout: [], + fields: [ + { + id: "title", + fieldId: "title", + storageId: "text@title", + type: "text", + label: "Title" + } + ], + layout: [["title"]], titleFieldId: "title" }), - createCmsModel({ + createCmsModelPlugin({ noValidate: true, modelId: "author", singularApiName: "Author", @@ -35,8 +43,16 @@ export const createMockModels = () => { group: group, name: "Author", description: "Author model", - fields: [], - layout: [], + fields: [ + { + id: "title", + fieldId: "title", + storageId: "text@title", + type: "text", + label: "Title" + } + ], + layout: [["title"]], titleFieldId: "title" }) ]; @@ -44,11 +60,19 @@ export const createMockModels = () => { export const createPrivateMockModels = () => { return [ - createCmsModel( + createCmsModelPlugin( createPrivateModel({ modelId: "private-model", name: "Private Model", - fields: [], + fields: [ + { + id: "title", + fieldId: "title", + storageId: "text@title", + type: "text", + label: "Title" + } + ], titleFieldId: "title" }) ) diff --git a/packages/api-headless-cms-bulk-actions/__tests__/tasks/createBulkAction.test.ts b/packages/api-headless-cms-bulk-actions/__tests__/tasks/createBulkAction.test.ts new file mode 100644 index 00000000000..d86abecd6af --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/__tests__/tasks/createBulkAction.test.ts @@ -0,0 +1,122 @@ +import { IntrospectionField, IntrospectionInterfaceType } from "graphql"; +import { useGraphQlHandler } from "~tests/context/useGraphQLHandler"; +import { createBulkAction } from "~/plugins"; +import { createMockModels, createPrivateMockModels } from "~tests/mocks"; + +interface GraphQlType { + kind: Uppercase; + name: string; + fields: IntrospectionField[]; + interfaces: IntrospectionInterfaceType[]; +} + +const getTypesFromResult = (result: any): GraphQlType[] => { + return result.data?.__schema?.types as GraphQlType[]; +}; + +const getEnumValues = (names: string[]) => { + return names.map(name => ({ + name, + description: null, + isDeprecated: false, + deprecationReason: null + })); +}; + +const defaultBulkActionsEnumNames = [ + "_empty", + "Delete", + "MoveToFolder", + "MoveToTrash", + "Publish", + "Unpublish", + "Restore" +]; + +describe("createBulkAction", () => { + it("should create GraphQL schema with default bulk actions ENUMS", async () => { + const { introspect } = useGraphQlHandler({ + plugins: [...createMockModels()] + }); + + const [result] = await introspect(); + const types = getTypesFromResult(result); + + const checks = ["BulkActionCarName", "BulkActionAuthorName"] as const; + for (const check of checks) { + const type = types.find(t => t.name === check); + expect(type).toMatchObject({ + kind: "ENUM", + name: expect.any(String), + enumValues: getEnumValues(defaultBulkActionsEnumNames), + description: null, + inputFields: null, + interfaces: null, + possibleTypes: null + }); + } + }); + + it("should NOT create bulk actions ENUMS in case of a private model", async () => { + const { introspect } = useGraphQlHandler({ + plugins: [...createPrivateMockModels()] + }); + + const [result] = await introspect(); + const types = getTypesFromResult(result); + + const forbiddens = ["BulkActionPrivateModelName"] as const; + for (const forbidden of forbiddens) { + const type = types.find(t => t.name === forbidden); + expect(type).toBeUndefined(); + } + }); + + it("should update the ENUMS when a new bulk action is created, only for the provided `modelIds`", async () => { + const dataLoader = jest.fn(); + const dataProcessor = jest.fn(); + + const { introspect } = useGraphQlHandler({ + plugins: [ + ...createMockModels(), + createBulkAction({ + name: "print", + dataLoader, + dataProcessor, + modelIds: ["car"] + }) + ] + }); + + const [result] = await introspect(); + const types = getTypesFromResult(result); + + const allowed = ["BulkActionCarName"] as const; + for (const check of allowed) { + const type = types.find(t => t.name === check); + expect(type).toMatchObject({ + kind: "ENUM", + name: expect.any(String), + enumValues: getEnumValues([...defaultBulkActionsEnumNames, "Print"]), + description: null, + inputFields: null, + interfaces: null, + possibleTypes: null + }); + } + + const forbidden = ["BulkActionAuthorName"] as const; + for (const check of forbidden) { + const type = types.find(t => t.name === check); + expect(type).toMatchObject({ + kind: "ENUM", + name: expect.any(String), + enumValues: getEnumValues([...defaultBulkActionsEnumNames]), + description: null, + inputFields: null, + interfaces: null, + possibleTypes: null + }); + } + }); +}); diff --git a/packages/api-headless-cms-tasks/jest.setup.js b/packages/api-headless-cms-bulk-actions/jest.setup.js similarity index 100% rename from packages/api-headless-cms-tasks/jest.setup.js rename to packages/api-headless-cms-bulk-actions/jest.setup.js diff --git a/packages/api-headless-cms-tasks/package.json b/packages/api-headless-cms-bulk-actions/package.json similarity index 81% rename from packages/api-headless-cms-tasks/package.json rename to packages/api-headless-cms-bulk-actions/package.json index 43141882ccd..16a1d8624a8 100644 --- a/packages/api-headless-cms-tasks/package.json +++ b/packages/api-headless-cms-bulk-actions/package.json @@ -1,22 +1,23 @@ { - "name": "@webiny/api-headless-cms-tasks", + "name": "@webiny/api-headless-cms-bulk-actions", "version": "0.0.0", "main": "index.js", - "description": "Background tasks for Webiny Headless CMS", + "description": "Webiny Headless CMS bulk actions", "keywords": [ - "api-headless-cms-tasks:base" + "api-headless-cms-bulk-actions:base" ], "repository": { "type": "git", "url": "https://github.com/webiny/webiny-js.git", - "directory": "packages/api-headless-cms-tasks" + "directory": "packages/api-headless-cms-bulk-actions" }, "license": "MIT", "dependencies": { "@webiny/api-headless-cms": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", - "@webiny/tasks": "0.0.0" + "@webiny/tasks": "0.0.0", + "@webiny/utils": "0.0.0" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-headless-cms-bulk-actions/src/abstractions/IListEntries.ts b/packages/api-headless-cms-bulk-actions/src/abstractions/IListEntries.ts new file mode 100644 index 00000000000..b0b78ab2be8 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/abstractions/IListEntries.ts @@ -0,0 +1,8 @@ +import { CmsEntry, CmsEntryListParams, CmsEntryMeta } from "@webiny/api-headless-cms/types"; + +export interface IListEntries { + execute: ( + modelId: string, + params: CmsEntryListParams + ) => Promise<{ entries: CmsEntry[]; meta: CmsEntryMeta }>; +} diff --git a/packages/api-headless-cms-bulk-actions/src/abstractions/IProcessEntry.ts b/packages/api-headless-cms-bulk-actions/src/abstractions/IProcessEntry.ts new file mode 100644 index 00000000000..bf573267efc --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/abstractions/IProcessEntry.ts @@ -0,0 +1,5 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; + +export interface IProcessEntry { + execute: (model: CmsModel, id: string, data?: any) => Promise; +} diff --git a/packages/api-headless-cms-tasks/src/tasks/IUseCase.ts b/packages/api-headless-cms-bulk-actions/src/abstractions/IUseCase.ts similarity index 100% rename from packages/api-headless-cms-tasks/src/tasks/IUseCase.ts rename to packages/api-headless-cms-bulk-actions/src/abstractions/IUseCase.ts diff --git a/packages/api-headless-cms-bulk-actions/src/abstractions/index.ts b/packages/api-headless-cms-bulk-actions/src/abstractions/index.ts new file mode 100644 index 00000000000..d9f3796bc39 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/abstractions/index.ts @@ -0,0 +1,3 @@ +export * from "./IListEntries"; +export * from "./IProcessEntry"; +export * from "./IUseCase"; diff --git a/packages/api-headless-cms-tasks/src/handlers/eventBridgeEventHandler.ts b/packages/api-headless-cms-bulk-actions/src/handlers/eventBridgeEventHandler.ts similarity index 89% rename from packages/api-headless-cms-tasks/src/handlers/eventBridgeEventHandler.ts rename to packages/api-headless-cms-bulk-actions/src/handlers/eventBridgeEventHandler.ts index 6a84159eef3..8165f7be399 100644 --- a/packages/api-headless-cms-tasks/src/handlers/eventBridgeEventHandler.ts +++ b/packages/api-headless-cms-bulk-actions/src/handlers/eventBridgeEventHandler.ts @@ -1,5 +1,5 @@ import { createEventBridgeEventHandler } from "@webiny/handler-aws"; -import { EntriesTask, HcmsTasksContext } from "~/types"; +import { HcmsBulkActionsContext } from "~/types"; const DETAIL_TYPE = "WebinyEmptyTrashBin"; @@ -14,7 +14,7 @@ export const createEventBridgeHandler = () => { return; } - const context = ctx as unknown as HcmsTasksContext; + const context = ctx as unknown as HcmsBulkActionsContext; if (!context.tasks || !context.tenancy) { console.error("Missing tasks or tenancy definition on context."); @@ -28,7 +28,7 @@ export const createEventBridgeHandler = () => { const tenants = await context.tenancy.listTenants(); await context.tenancy.withEachTenant(tenants, async () => { await context.tasks.trigger({ - definition: EntriesTask.EmptyTrashBins + definition: "hcmsEntriesEmptyTrashBins" }); }); diff --git a/packages/api-headless-cms-tasks/src/handlers/index.ts b/packages/api-headless-cms-bulk-actions/src/handlers/index.ts similarity index 100% rename from packages/api-headless-cms-tasks/src/handlers/index.ts rename to packages/api-headless-cms-bulk-actions/src/handlers/index.ts diff --git a/packages/api-headless-cms-tasks/src/handlers/setupEventsTenant.ts b/packages/api-headless-cms-bulk-actions/src/handlers/setupEventsTenant.ts similarity index 100% rename from packages/api-headless-cms-tasks/src/handlers/setupEventsTenant.ts rename to packages/api-headless-cms-bulk-actions/src/handlers/setupEventsTenant.ts diff --git a/packages/api-headless-cms-bulk-actions/src/index.ts b/packages/api-headless-cms-bulk-actions/src/index.ts new file mode 100644 index 00000000000..9ea9e9a5b5f --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/index.ts @@ -0,0 +1,13 @@ +import { createTasks } from "~/tasks"; +import { createHandlers } from "~/handlers"; +import { createDefaultGraphQL } from "~/plugins"; + +export * from "./abstractions"; +export * from "./useCases"; +export * from "./plugins"; + +export const createHcmsBulkActions = () => [ + createTasks(), + createHandlers(), + createDefaultGraphQL() +]; diff --git a/packages/api-headless-cms-bulk-actions/src/plugins/createBulkAction.ts b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkAction.ts new file mode 100644 index 00000000000..155ef12b5e2 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkAction.ts @@ -0,0 +1,41 @@ +import { createBulkActionGraphQL } from "./createBulkActionGraphQL"; +import { createBulkActionTasks } from "~/plugins/createBulkActionTasks"; +import { IListEntries, IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +export interface CreateBulkActionConfig { + name: string; + dataLoader: (context: HcmsBulkActionsContext) => IListEntries; + dataProcessor: (context: HcmsBulkActionsContext) => IProcessEntry; + modelIds?: string[]; +} + +function toPascalCase(str: string) { + // Step 1: Remove non-alphanumeric characters and replace them with spaces + str = str.replace(/[^a-zA-Z0-9]+/g, " "); + + // Step 2: Split the string into words + const words = str.split(" "); + + // Step 3: Capitalize the first letter of each word + const capitalizedWords = words.map(word => word.charAt(0).toUpperCase() + word.slice(1)); + + // Step 4: Join all the capitalized words together + return capitalizedWords.join(""); +} + +export const createBulkAction = (config: CreateBulkActionConfig) => { + const name = toPascalCase(config.name); + + return [ + createBulkActionTasks({ + name, + dataLoader: config.dataLoader, + dataProcessor: config.dataProcessor + }), + createBulkActionGraphQL({ + name, + modelIds: config.modelIds + }) + ]; +}; diff --git a/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionGraphQL.ts b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionGraphQL.ts new file mode 100644 index 00000000000..b7f5658f75c --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionGraphQL.ts @@ -0,0 +1,65 @@ +import { ContextPlugin } from "@webiny/api"; +import { HcmsBulkActionsContext } from "~/types"; +import { CmsGraphQLSchemaPlugin, isHeadlessCmsReady } from "@webiny/api-headless-cms"; +import { Response } from "@webiny/handler-graphql"; + +export interface CreateBulkActionGraphQL { + name: string; + modelIds?: string[]; +} + +export const createBulkActionGraphQL = (config: CreateBulkActionGraphQL) => { + return new ContextPlugin(async context => { + if (!(await isHeadlessCmsReady(context))) { + return; + } + + const models = await context.security.withoutAuthorization(async () => { + const allModels = await context.cms.listModels(); + return allModels.filter( + model => + !model.isPrivate && + (!config.modelIds?.length || config.modelIds.includes(model.modelId)) + ); + }); + + const plugins: CmsGraphQLSchemaPlugin[] = []; + + models.forEach(model => { + const plugin = new CmsGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + extend enum BulkAction${model.singularApiName}Name { + ${config.name} + } + `, + resolvers: { + Mutation: { + [`bulkAction${model.singularApiName}`]: async (_, args) => { + const identity = context.security.getIdentity(); + + const response = await context.tasks.trigger({ + definition: `hcmsBulkList${args.action}Entries`, + input: { + modelId: model.modelId, + where: args.where, + search: args.search, + data: args.data, + identity + } + }); + + return new Response({ + id: response.id + }); + } + } + } + }); + + plugin.name = `headless-cms.graphql.schema.bulkAction.${model.modelId}.${config.name}`; + plugins.push(plugin); + }); + + context.plugins.register([...plugins]); + }); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionTasks.ts b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionTasks.ts new file mode 100644 index 00000000000..ce148e88331 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/plugins/createBulkActionTasks.ts @@ -0,0 +1,136 @@ +import { createPrivateTaskDefinition } from "@webiny/tasks"; +import { IListEntries, IProcessEntry } from "~/abstractions"; +import { + ChildTasksCleanup, + CreateTasksByModel, + ProcessTask, + ProcessTasksByModel +} from "~/useCases/internals"; +import { + HcmsBulkActionsContext, + IBulkActionOperationByModelInput, + IBulkActionOperationByModelOutput, + IBulkActionOperationInput, + IBulkActionOperationOutput +} from "~/types"; + +export interface CreateBackgroundTasksConfig { + name: string; + dataLoader: (context: HcmsBulkActionsContext) => IListEntries; + dataProcessor: (context: HcmsBulkActionsContext) => IProcessEntry; +} + +class BulkActionTasks { + private readonly name: string; + private readonly dataLoader: (context: HcmsBulkActionsContext) => IListEntries; + private readonly dataProcessor: (context: HcmsBulkActionsContext) => IProcessEntry; + + constructor(config: CreateBackgroundTasksConfig) { + this.name = config.name; + this.dataLoader = config.dataLoader; + this.dataProcessor = config.dataProcessor; + } + + public createListTaskDefinition() { + return createPrivateTaskDefinition< + HcmsBulkActionsContext, + IBulkActionOperationByModelInput, + IBulkActionOperationByModelOutput + >({ + id: this.createListTaskDefinitionName(), + title: `Headless CMS: list "${this.name}" entries by model`, + maxIterations: 500, + run: async params => { + const { response, input, context } = params; + + try { + if (!input.modelId) { + return response.error(`Missing "modelId" in the input.`); + } + + if (input.processing) { + const processTasks = new ProcessTasksByModel( + this.createProcessTaskDefinitionName() + ); + return await processTasks.execute(params); + } + + const createTasks = new CreateTasksByModel( + this.createProcessTaskDefinitionName(), + this.dataLoader(context) + ); + return await createTasks.execute(params); + } catch (ex) { + return response.error(ex.message ?? "Error while executing list task"); + } + }, + onDone: async ({ context, task }) => { + /** + * We want to clean all child tasks and logs, which have no errors. + */ + const childTasksCleanup = new ChildTasksCleanup(); + try { + await childTasksCleanup.execute({ + context, + task + }); + } catch (ex) { + console.error("Error while cleaning list child tasks.", ex); + } + } + }); + } + + public createProcessTaskDefinition() { + return createPrivateTaskDefinition< + HcmsBulkActionsContext, + IBulkActionOperationInput, + IBulkActionOperationOutput + >({ + id: this.createProcessTaskDefinitionName(), + title: `Headless CMS: process "${this.name}" entries`, + maxIterations: 2, + run: async params => { + const { response, context } = params; + + try { + const processTask = new ProcessTask(this.dataProcessor(context)); + return await processTask.execute(params); + } catch (ex) { + return response.error(ex.message ?? "Error while executing process task"); + } + }, + onDone: async ({ context, task }) => { + /** + * We want to clean all child tasks and logs, which have no errors. + */ + const childTasksCleanup = new ChildTasksCleanup(); + try { + await childTasksCleanup.execute({ + context, + task + }); + } catch (ex) { + console.error("Error while cleaning process child tasks.", ex); + } + } + }); + } + + private createListTaskDefinitionName() { + return `hcmsBulkList${this.name}Entries`; + } + + private createProcessTaskDefinitionName() { + return `hcmsBulkProcess${this.name}Entries`; + } +} + +export const createBulkActionTasks = (config: CreateBackgroundTasksConfig) => { + const backgroundTasks = new BulkActionTasks(config); + + return [ + backgroundTasks.createListTaskDefinition(), + backgroundTasks.createProcessTaskDefinition() + ]; +}; diff --git a/packages/api-headless-cms-bulk-actions/src/plugins/createDefaultGraphQL.ts b/packages/api-headless-cms-bulk-actions/src/plugins/createDefaultGraphQL.ts new file mode 100644 index 00000000000..1dba3223372 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/plugins/createDefaultGraphQL.ts @@ -0,0 +1,56 @@ +import { ContextPlugin } from "@webiny/api"; +import { HcmsBulkActionsContext } from "~/types"; +import { CmsGraphQLSchemaPlugin, isHeadlessCmsReady } from "@webiny/api-headless-cms"; + +export const createDefaultGraphQL = () => { + return new ContextPlugin(async context => { + if (!(await isHeadlessCmsReady(context))) { + return; + } + + const defaultPlugin = new CmsGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type BulkActionResponseData { + id: String + } + + type BulkActionResponse { + data: BulkActionResponseData + error: CmsError + } + ` + }); + defaultPlugin.name = `headless-cms.graphql.schema.bulkAction.default`; + + const models = await context.security.withoutAuthorization(async () => { + const allModels = await context.cms.listModels(); + return allModels.filter(model => !model.isPrivate); + }); + + const modelPlugins: CmsGraphQLSchemaPlugin[] = []; + + models.forEach(model => { + const plugin = new CmsGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + enum BulkAction${model.singularApiName}Name { + _empty + } + + extend type Mutation { + bulkAction${model.singularApiName}( + action: BulkAction${model.singularApiName}Name! + where: ${model.singularApiName}ListWhereInput + search: String + data: JSON + ): BulkActionResponse + } + ` + }); + + plugin.name = `headless-cms.graphql.schema.bulkAction.default.${model.modelId}`; + modelPlugins.push(plugin); + }); + + context.plugins.register([defaultPlugin, ...modelPlugins]); + }); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/plugins/index.ts b/packages/api-headless-cms-bulk-actions/src/plugins/index.ts new file mode 100644 index 00000000000..7b63b2e872b --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from "./createBulkAction"; +export * from "./createDefaultGraphQL"; diff --git a/packages/api-headless-cms-bulk-actions/src/tasks/createBulkActionEntriesTasks.ts b/packages/api-headless-cms-bulk-actions/src/tasks/createBulkActionEntriesTasks.ts new file mode 100644 index 00000000000..b42e8c17aad --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/tasks/createBulkActionEntriesTasks.ts @@ -0,0 +1,47 @@ +import { createBulkAction } from "~/plugins"; +import { + createDeleteEntry, + createListDeletedEntries, + createListLatestEntries, + createListPublishedEntries, + createMoveEntryToFolder, + createMoveEntryToTrash, + createPublishEntry, + createRestoreEntryFromTrash, + createUnpublishEntry +} from "~/useCases"; + +export const createBulkActionEntriesTasks = () => { + return [ + createBulkAction({ + name: "delete", + dataLoader: createListDeletedEntries, + dataProcessor: createDeleteEntry + }), + createBulkAction({ + name: "moveToFolder", + dataLoader: createListLatestEntries, + dataProcessor: createMoveEntryToFolder + }), + createBulkAction({ + name: "moveToTrash", + dataLoader: createListLatestEntries, + dataProcessor: createMoveEntryToTrash + }), + createBulkAction({ + name: "publish", + dataLoader: createListLatestEntries, + dataProcessor: createPublishEntry + }), + createBulkAction({ + name: "unpublish", + dataLoader: createListPublishedEntries, + dataProcessor: createUnpublishEntry + }), + createBulkAction({ + name: "restore", + dataLoader: createListDeletedEntries, + dataProcessor: createRestoreEntryFromTrash + }) + ]; +}; diff --git a/packages/api-headless-cms-bulk-actions/src/tasks/createEmptyTrashBinsTask.ts b/packages/api-headless-cms-bulk-actions/src/tasks/createEmptyTrashBinsTask.ts new file mode 100644 index 00000000000..3d14076a513 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/tasks/createEmptyTrashBinsTask.ts @@ -0,0 +1,82 @@ +import { createTaskDefinition } from "@webiny/tasks"; +import { HcmsBulkActionsContext, IBulkActionOperationByModelInput } from "~/types"; +import { ChildTasksCleanup } from "~/useCases/internals"; + +const calculateDateTimeString = () => { + // Retrieve the retention period from the environment variable WEBINY_TRASH_BIN_RETENTION_PERIOD_DAYS, + // or default to 90 days if not set or set to 0. + const retentionPeriodFromEnv = process.env["WEBINY_TRASH_BIN_RETENTION_PERIOD_DAYS"]; + const retentionPeriod = + retentionPeriodFromEnv && Number(retentionPeriodFromEnv) !== 0 + ? Number(retentionPeriodFromEnv) + : 90; + + // Calculate the date-time by subtracting the retention period (in days) from the current date. + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - retentionPeriod); + + // Return the calculated date-time string in ISO 8601 format. + return currentDate.toISOString(); +}; + +export const createEmptyTrashBinsTask = () => { + return createTaskDefinition({ + id: "hcmsEntriesEmptyTrashBins", + title: "Headless CMS - Empty all trash bins", + description: + "Delete all entries found in the trash bin, for each model found in the system.", + maxIterations: 1, + run: async params => { + const { response, isAborted, context } = params; + + try { + if (isAborted()) { + return response.aborted(); + } + + const locales = context.i18n.getLocales(); + + await context.i18n.withEachLocale(locales, async () => { + const models = await context.security.withoutAuthorization(async () => { + return (await context.cms.listModels()).filter(model => !model.isPrivate); + }); + + for (const model of models) { + await context.tasks.trigger({ + name: `Headless CMS - Empty trash bin for "${model.name}" model.`, + definition: "hcmsBulkListDeleteEntries", + parent: params.store.getTask(), + input: { + modelId: model.modelId, + where: { + deletedOn_lt: calculateDateTimeString() + } + } + }); + } + return; + }); + + return response.done( + `Task done: emptying the trash bin for all registered models.` + ); + } catch (ex) { + return response.error(ex.message ?? "Error while executing EmptyTrashBins task"); + } + }, + onDone: async ({ context, task }) => { + /** + * We want to clean all child tasks and logs, which have no errors. + */ + const childTasksCleanup = new ChildTasksCleanup(); + try { + await childTasksCleanup.execute({ + context, + task + }); + } catch (ex) { + console.error("Error while cleaning `EmptyTrashBins` child tasks.", ex); + } + } + }); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/tasks/index.ts b/packages/api-headless-cms-bulk-actions/src/tasks/index.ts new file mode 100644 index 00000000000..d42ebc472b7 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/tasks/index.ts @@ -0,0 +1,6 @@ +import { createBulkActionEntriesTasks } from "./createBulkActionEntriesTasks"; +import { createEmptyTrashBinsTask } from "./createEmptyTrashBinsTask"; + +export const createTasks = () => { + return [createBulkActionEntriesTasks(), createEmptyTrashBinsTask()]; +}; diff --git a/packages/api-headless-cms-bulk-actions/src/types.ts b/packages/api-headless-cms-bulk-actions/src/types.ts new file mode 100644 index 00000000000..c02a9e553d2 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/types.ts @@ -0,0 +1,61 @@ +import { CmsContext } from "@webiny/api-headless-cms/types"; +import { Context as BaseContext } from "@webiny/handler/types"; +import { + Context as TasksContext, + ITaskResponseDoneResultOutput, + ITaskRunParams +} from "@webiny/tasks/types"; +import { SecurityIdentity } from "@webiny/api-security/types"; + +export interface HcmsBulkActionsContext extends BaseContext, CmsContext, TasksContext {} + +/** + * Bulk Action Operation + */ + +export interface IBulkActionOperationInput { + modelId: string; + ids: string[]; + data?: Record; + identity: SecurityIdentity; + done?: string[]; + failed?: string[]; +} + +export interface IBulkActionOperationOutput extends ITaskResponseDoneResultOutput { + done: string[]; + failed: string[]; +} + +export type IBulkActionOperationTaskParams = ITaskRunParams< + HcmsBulkActionsContext, + IBulkActionOperationInput, + IBulkActionOperationOutput +>; + +/** + * Bulk Action Operation By Model + */ + +export interface IBulkActionOperationByModelInput { + modelId: string; + identity?: SecurityIdentity; + where?: Record; + search?: string; + data?: Record; + after?: string | null; + currentBatch?: number; + processing?: boolean; + totalCount?: number; +} + +export interface IBulkActionOperationByModelOutput extends ITaskResponseDoneResultOutput { + done: string[]; + failed: string[]; +} + +export type IBulkActionOperationByModelTaskParams = ITaskRunParams< + HcmsBulkActionsContext, + IBulkActionOperationByModelInput, + IBulkActionOperationByModelOutput +>; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/DeleteEntry.ts b/packages/api-headless-cms-bulk-actions/src/useCases/DeleteEntry.ts new file mode 100644 index 00000000000..a620198e196 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/DeleteEntry.ts @@ -0,0 +1,23 @@ +import { parseIdentifier } from "@webiny/utils"; +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class DeleteEntry implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string): Promise { + const { id: entryId } = parseIdentifier(id); + await this.context.cms.deleteEntry(model, entryId, { + permanently: true + }); + } +} + +export const createDeleteEntry = (context: HcmsBulkActionsContext) => { + return new DeleteEntry(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/ListDeletedEntries.ts b/packages/api-headless-cms-bulk-actions/src/useCases/ListDeletedEntries.ts new file mode 100644 index 00000000000..4abfe52cdd1 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/ListDeletedEntries.ts @@ -0,0 +1,30 @@ +import { CmsEntryListParams } from "@webiny/api-headless-cms/types"; +import { IListEntries } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class ListDeletedEntries implements IListEntries { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(modelId: string, params: CmsEntryListParams) { + const model = await this.context.cms.getModel(modelId); + + if (!model) { + throw new Error(`Model with ${modelId} not found!`); + } + + const [entries, meta] = await this.context.cms.listDeletedEntries(model, params); + + return { + entries, + meta + }; + } +} + +export const createListDeletedEntries = (context: HcmsBulkActionsContext) => { + return new ListDeletedEntries(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/ListLatestEntries.ts b/packages/api-headless-cms-bulk-actions/src/useCases/ListLatestEntries.ts new file mode 100644 index 00000000000..5c93d0f0b1e --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/ListLatestEntries.ts @@ -0,0 +1,30 @@ +import { CmsEntryListParams } from "@webiny/api-headless-cms/types"; +import { IListEntries } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class ListLatestEntries implements IListEntries { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(modelId: string, params: CmsEntryListParams) { + const model = await this.context.cms.getModel(modelId); + + if (!model) { + throw new Error(`Model with ${modelId} not found!`); + } + + const [entries, meta] = await this.context.cms.listLatestEntries(model, params); + + return { + entries, + meta + }; + } +} + +export const createListLatestEntries = (context: HcmsBulkActionsContext) => { + return new ListLatestEntries(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/ListPublishedEntries.ts b/packages/api-headless-cms-bulk-actions/src/useCases/ListPublishedEntries.ts new file mode 100644 index 00000000000..0e1e0f80057 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/ListPublishedEntries.ts @@ -0,0 +1,30 @@ +import { CmsEntryListParams } from "@webiny/api-headless-cms/types"; +import { IListEntries } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class ListPublishedEntries implements IListEntries { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(modelId: string, params: CmsEntryListParams) { + const model = await this.context.cms.getModel(modelId); + + if (!model) { + throw new Error(`Model with ${modelId} not found!`); + } + + const [entries, meta] = await this.context.cms.listPublishedEntries(model, params); + + return { + entries, + meta + }; + } +} + +export const createListPublishedEntries = (context: HcmsBulkActionsContext) => { + return new ListPublishedEntries(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToFolder.ts b/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToFolder.ts new file mode 100644 index 00000000000..8bc9c7160ab --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToFolder.ts @@ -0,0 +1,26 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +interface MoveEntryToFolderData { + folderId?: string; +} + +class MoveEntryToFolder implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string, data?: MoveEntryToFolderData): Promise { + if (!data?.folderId) { + throw new Error(`Missing "data.folderId" in the input.`); + } + await this.context.cms.moveEntry(model, id, data.folderId); + } +} + +export const createMoveEntryToFolder = (context: HcmsBulkActionsContext) => { + return new MoveEntryToFolder(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToTrash.ts b/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToTrash.ts new file mode 100644 index 00000000000..40a0374faf4 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/MoveEntryToTrash.ts @@ -0,0 +1,21 @@ +import { parseIdentifier } from "@webiny/utils"; +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class MoveEntryToTrash implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string): Promise { + const { id: entryId } = parseIdentifier(id); + await this.context.cms.deleteEntry(model, entryId, { permanently: false }); + } +} + +export const createMoveEntryToTrash = (context: HcmsBulkActionsContext) => { + return new MoveEntryToTrash(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/PublishEntry.ts b/packages/api-headless-cms-bulk-actions/src/useCases/PublishEntry.ts new file mode 100644 index 00000000000..5569e69c88c --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/PublishEntry.ts @@ -0,0 +1,19 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class PublishEntry implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string): Promise { + await this.context.cms.publishEntry(model, id); + } +} + +export const createPublishEntry = (context: HcmsBulkActionsContext) => { + return new PublishEntry(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/RestoreEntryFromTrash.ts b/packages/api-headless-cms-bulk-actions/src/useCases/RestoreEntryFromTrash.ts new file mode 100644 index 00000000000..ab879063918 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/RestoreEntryFromTrash.ts @@ -0,0 +1,21 @@ +import { parseIdentifier } from "@webiny/utils"; +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class RestoreEntryFromTrash implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string): Promise { + const { id: entryId } = parseIdentifier(id); + await this.context.cms.restoreEntryFromBin(model, entryId); + } +} + +export const createRestoreEntryFromTrash = (context: HcmsBulkActionsContext) => { + return new RestoreEntryFromTrash(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/UnpublishEntry.ts b/packages/api-headless-cms-bulk-actions/src/useCases/UnpublishEntry.ts new file mode 100644 index 00000000000..2974ef68135 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/UnpublishEntry.ts @@ -0,0 +1,19 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { IProcessEntry } from "~/abstractions"; +import { HcmsBulkActionsContext } from "~/types"; + +class UnpublishEntry implements IProcessEntry { + private readonly context: HcmsBulkActionsContext; + + constructor(context: HcmsBulkActionsContext) { + this.context = context; + } + + async execute(model: CmsModel, id: string): Promise { + await this.context.cms.unpublishEntry(model, id); + } +} + +export const createUnpublishEntry = (context: HcmsBulkActionsContext) => { + return new UnpublishEntry(context); +}; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/index.ts new file mode 100644 index 00000000000..f499e2c8cc2 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/index.ts @@ -0,0 +1,9 @@ +export * from "./DeleteEntry"; +export * from "./ListDeletedEntries"; +export * from "./ListLatestEntries"; +export * from "./ListPublishedEntries"; +export * from "./MoveEntryToFolder"; +export * from "./MoveEntryToTrash"; +export * from "./PublishEntry"; +export * from "./RestoreEntryFromTrash"; +export * from "./UnpublishEntry"; diff --git a/packages/api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup/ChildTasksCleanup.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup/ChildTasksCleanup.ts similarity index 97% rename from packages/api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup/ChildTasksCleanup.ts rename to packages/api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup/ChildTasksCleanup.ts index 881fd001ba7..0eb92a4cd32 100644 --- a/packages/api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup/ChildTasksCleanup.ts +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup/ChildTasksCleanup.ts @@ -1,5 +1,5 @@ import { ITask, Context, ITaskLogItemType } from "@webiny/tasks"; -import { IUseCase } from "~/tasks/IUseCase"; +import { IUseCase } from "~/abstractions"; export interface IChildTasksCleanupExecuteParams { context: Context; diff --git a/packages/api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup/index.ts similarity index 100% rename from packages/api-headless-cms-tasks/src/tasks/common/useCases/ChildTasksCleanup/index.ts rename to packages/api-headless-cms-bulk-actions/src/useCases/internals/ChildTaskCleanup/index.ts diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/CreateTasksByModel.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/CreateTasksByModel.ts new file mode 100644 index 00000000000..199ee98fd0c --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/CreateTasksByModel.ts @@ -0,0 +1,109 @@ +import { ITaskResponseResult } from "@webiny/tasks"; +import { TaskCache } from "./TaskCache"; +import { CmsEntryListParams } from "@webiny/api-headless-cms/types"; +import { IListEntries } from "~/abstractions"; +import { IBulkActionOperationByModelTaskParams } from "~/types"; + +const BATCH_SIZE = 50; // Number of entries to fetch in each batch +const WAITING_TIME = 5; // Time to wait in seconds before retrying + +/** + * The `CreateTasksByModel` class handles the execution of a task to process entries in batches. + */ +export class CreateTasksByModel { + private readonly taskCache: TaskCache; + private listEntriesGateway: IListEntries; + + constructor(taskDefinition: string, listEntriesGateway: IListEntries) { + this.taskCache = new TaskCache(taskDefinition); + this.listEntriesGateway = listEntriesGateway; + } + + async execute(params: IBulkActionOperationByModelTaskParams): Promise { + const { response, input, isAborted, isCloseToTimeout, context, store } = params; + + try { + const listEntriesParams: CmsEntryListParams = { + where: input.where, + search: input.search, + after: input.after, + limit: BATCH_SIZE + }; + + let currentBatch = input.currentBatch || 1; + + while (true) { + if (isAborted()) { + return response.aborted(); + } else if (isCloseToTimeout()) { + await this.taskCache.triggerTask(context, store.getTask()); + return response.continue({ + ...input, + ...listEntriesParams, + currentBatch + }); + } + + // List entries from the HCMS based on the provided query + const { entries, meta } = await this.listEntriesGateway.execute( + input.modelId, + listEntriesParams + ); + + // End the task if no entries match the query + if (meta.totalCount === 0) { + return response.done( + `Task done: no entries found for model "${input.modelId}", skipping task creation.` + ); + } + + // Continue processing if no entries are returned in the current batch + if (entries.length === 0) { + await this.taskCache.triggerTask(context, store.getTask()); + return response.continue( + { + ...input, + ...listEntriesParams, + currentBatch, + totalCount: meta.totalCount, + processing: true + }, + { seconds: WAITING_TIME } + ); + } + + const ids = entries.map(entry => entry.id); // Extract entry IDs + + if (ids.length > 0) { + // Cache the task with the entry IDs + this.taskCache.cacheTask({ + modelId: input.modelId, + identity: input.identity, + data: input.data, + ids + }); + } + + // Continue processing if there are no more entries or pagination is complete + if (!meta.hasMoreItems || !meta.cursor) { + await this.taskCache.triggerTask(context, store.getTask()); + return response.continue( + { + ...input, + ...listEntriesParams, + currentBatch, + totalCount: meta.totalCount, + processing: true + }, + { seconds: WAITING_TIME } + ); + } + + listEntriesParams.after = meta.cursor; + currentBatch++; + } + } catch (ex) { + throw new Error(ex.message ?? `Error while creating task.`); + } + } +} diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/TaskCache.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/TaskCache.ts new file mode 100644 index 00000000000..0325625aad9 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/TaskCache.ts @@ -0,0 +1,66 @@ +import { HcmsBulkActionsContext } from "~/types"; +import { ITask, ITaskDataInput } from "@webiny/tasks"; + +/** + * TaskCache class for managing and triggering cached tasks. + * @template TTask - Task input data. + */ +export class TaskCache { + private readonly taskDefinition: string; + private taskCache: TTask[] = []; + + constructor(taskDefinition: string) { + this.taskDefinition = taskDefinition; + } + + /** + * Adds a task to the cache. + * @param {TTask} item - The task input data to be cached. + */ + cacheTask(item: TTask) { + this.taskCache.push(item); + } + + /** + * Triggers all cached tasks using the provided context and parent task. + * @param {HcmsBulkActionsContext} context - The context used to trigger the tasks. + * @param {ITask} parent - The parent task to associate with the triggered tasks. + */ + async triggerTask(context: HcmsBulkActionsContext, parent: ITask) { + const tasks = this.getTasks(); + + if (tasks.length === 0) { + return; + } + + for (const task of tasks) { + try { + await context.tasks.trigger({ + definition: this.taskDefinition, + parent, + input: task + }); + } catch (error) { + console.error(`Error triggering task.`, error); + } + } + + // Clear the cache after processing + this.clearTasks(); + } + + /** + * Retrieves the cached tasks. + * @returns {TTask[]} The list of cached tasks. + */ + private getTasks() { + return this.taskCache; + } + + /** + * Clears all cached tasks. + */ + private clearTasks() { + this.taskCache = []; + } +} diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/index.ts new file mode 100644 index 00000000000..f416f1744de --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/CreateTasksByModel/index.ts @@ -0,0 +1 @@ +export * from "./CreateTasksByModel"; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/ProcessTask.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/ProcessTask.ts new file mode 100644 index 00000000000..f15dce4e63c --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/ProcessTask.ts @@ -0,0 +1,87 @@ +import { Result } from "./Result"; +import { IBulkActionOperationTaskParams } from "~/types"; +import { IProcessEntry } from "~/abstractions"; + +/** + * The `ProcessTask` class is responsible for processing a batch of entries + * based on the provided parameters. It uses a gateway to perform the actual + * processing and maintains the results of the operations, including successfully + * processed and failed entries. + */ +export class ProcessTask { + private readonly result: Result; + private gateway: IProcessEntry; + + constructor(gateway: IProcessEntry) { + this.result = new Result(); + this.gateway = gateway; + } + + async execute(params: IBulkActionOperationTaskParams) { + const { input, response, isAborted, isCloseToTimeout, context, store } = params; + + try { + if (isAborted()) { + return response.aborted(); + } else if (isCloseToTimeout()) { + return response.continue({ + ...input + }); + } + + // Check if the input contains a model ID. + if (!input.modelId) { + return response.error(`Missing "modelId" in the input.`); + } + + // Check if the model exists. + const model = await context.cms.getModel(input.modelId); + + if (!model) { + return response.error(`Model with ${input.modelId} not found!`); + } + + // Check if there are any IDs to process. + if (!input.ids || input.ids.length === 0) { + return response.done( + `Task done: no entries to process for "${input.modelId}" model.` + ); + } + + // Process each ID in the input. + for (const id of input.ids) { + try { + // Set the security identity in the context. + context.security.setIdentity(input.identity); + // Execute the gateway operation for the current ID. + await this.gateway.execute(model, id, input.data); + // Add the ID to the list of successfully processed entries. + this.result.addDone(id); + } catch (ex) { + // Handle any errors that occur during processing of the current ID. + const message = ex.message || `Failed to process entry with id "${id}".`; + try { + console.error(message); + await store.addErrorLog({ + message, + error: ex + }); + } catch { + console.error(`Failed to add error log: "${message}"`); + } finally { + // Add the ID to the list of failed entries. + this.result.addFailed(id); + } + } + } + + // Return a done response with the results of the processing. + return response.done(`Task done: all entries processed for "${model.name}" model.`, { + done: this.result.getDone(), + failed: this.result.getFailed() + }); + } catch (ex) { + return response.error(ex.message ?? `Error while processing task.`); + } + } +} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepository.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/Result.ts similarity index 94% rename from packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepository.ts rename to packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/Result.ts index 197b49c6ad4..6cd4e3f782c 100644 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepository.ts +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/Result.ts @@ -1,4 +1,4 @@ -export class TaskRepository { +export class Result { private readonly done: Set; private readonly failed: Set; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/index.ts new file mode 100644 index 00000000000..f43e72c3485 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTask/index.ts @@ -0,0 +1 @@ +export * from "./ProcessTask"; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/ProcessTasksByModel.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/ProcessTasksByModel.ts new file mode 100644 index 00000000000..6be66d57042 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/ProcessTasksByModel.ts @@ -0,0 +1,60 @@ +import { TaskDataStatus } from "@webiny/tasks"; +import { IBulkActionOperationByModelTaskParams } from "~/types"; + +const WAITING_TIME = 10; + +/** + * The `ProcessTasksByModel` class is responsible for processing tasks for a specific model. + * It checks for any running or pending tasks from the parent task and continues or completes + * the task based on the status. + */ +export class ProcessTasksByModel { + private taskDefinition: string; + + constructor(taskDefinition: string) { + this.taskDefinition = taskDefinition; + } + + async execute(params: IBulkActionOperationByModelTaskParams) { + const { response, input, isAborted, isCloseToTimeout, context, store } = params; + + try { + if (isAborted()) { + return response.aborted(); + } else if (isCloseToTimeout()) { + return response.continue({ + ...input + }); + } + + const result = await context.tasks.listTasks({ + where: { + parentId: store.getTask().id, + definitionId: this.taskDefinition, + taskStatus_in: [TaskDataStatus.RUNNING, TaskDataStatus.PENDING] + }, + limit: 1 + }); + + // If there are running or pending tasks, continue with a wait. + if (result.items.length > 0) { + return response.continue( + { + ...input + }, + { + seconds: WAITING_TIME + } + ); + } + + return response.done( + `Task done: task "${this.taskDefinition}" has been successfully processed for entries from "${input.modelId}" model.` + ); + } catch (ex) { + return response.error( + ex.message ?? `Error while processing task "${this.taskDefinition}"` + ); + } + } +} diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/index.ts new file mode 100644 index 00000000000..adf4bf317d2 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/ProcessTasksByModel/index.ts @@ -0,0 +1 @@ +export * from "./ProcessTasksByModel"; diff --git a/packages/api-headless-cms-bulk-actions/src/useCases/internals/index.ts b/packages/api-headless-cms-bulk-actions/src/useCases/internals/index.ts new file mode 100644 index 00000000000..69b10b6edd1 --- /dev/null +++ b/packages/api-headless-cms-bulk-actions/src/useCases/internals/index.ts @@ -0,0 +1,4 @@ +export * from "./ChildTaskCleanup"; +export * from "./CreateTasksByModel"; +export * from "./ProcessTask"; +export * from "./ProcessTasksByModel"; diff --git a/packages/api-headless-cms-tasks/tsconfig.build.json b/packages/api-headless-cms-bulk-actions/tsconfig.build.json similarity index 95% rename from packages/api-headless-cms-tasks/tsconfig.build.json rename to packages/api-headless-cms-bulk-actions/tsconfig.build.json index 9da4431c8d4..a666b40ec94 100644 --- a/packages/api-headless-cms-tasks/tsconfig.build.json +++ b/packages/api-headless-cms-bulk-actions/tsconfig.build.json @@ -3,17 +3,18 @@ "include": ["src"], "references": [ { "path": "../api-headless-cms/tsconfig.build.json" }, - { "path": "../handler/tsconfig.build.json" }, - { "path": "../handler-aws/tsconfig.build.json" }, - { "path": "../tasks/tsconfig.build.json" }, { "path": "../api/tsconfig.build.json" }, { "path": "../api-admin-users/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-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, + { "path": "../tasks/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, { "path": "../wcp/tsconfig.build.json" } ], "compilerOptions": { diff --git a/packages/api-headless-cms-tasks/tsconfig.json b/packages/api-headless-cms-bulk-actions/tsconfig.json similarity index 94% rename from packages/api-headless-cms-tasks/tsconfig.json rename to packages/api-headless-cms-bulk-actions/tsconfig.json index 650a13e104c..ff0656762cd 100644 --- a/packages/api-headless-cms-tasks/tsconfig.json +++ b/packages/api-headless-cms-bulk-actions/tsconfig.json @@ -2,18 +2,19 @@ "extends": "../../tsconfig.json", "include": ["src", "__tests__"], "references": [ - { "path": "../api-headless-cms" }, - { "path": "../handler" }, - { "path": "../handler-aws" }, - { "path": "../tasks" }, { "path": "../api" }, { "path": "../api-admin-users" }, + { "path": "../api-headless-cms" }, { "path": "../api-i18n" }, { "path": "../api-security" }, { "path": "../api-tenancy" }, { "path": "../api-wcp" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, + { "path": "../tasks" }, + { "path": "../utils" }, { "path": "../wcp" } ], "compilerOptions": { @@ -23,18 +24,12 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], - "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/handler/*": ["../handler/src/*"], - "@webiny/handler": ["../handler/src"], - "@webiny/handler-aws/*": ["../handler-aws/src/*"], - "@webiny/handler-aws": ["../handler-aws/src"], - "@webiny/tasks/*": ["../tasks/src/*"], - "@webiny/tasks": ["../tasks/src"], "@webiny/api/*": ["../api/src/*"], "@webiny/api": ["../api/src"], "@webiny/api-admin-users/*": ["../api-admin-users/src/*"], "@webiny/api-admin-users": ["../api-admin-users/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/*"], @@ -43,10 +38,18 @@ "@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-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/wcp/*": ["../wcp/src/*"], "@webiny/wcp": ["../wcp/src"] }, diff --git a/packages/api-headless-cms-tasks/webiny.config.js b/packages/api-headless-cms-bulk-actions/webiny.config.js similarity index 100% rename from packages/api-headless-cms-tasks/webiny.config.js rename to packages/api-headless-cms-bulk-actions/webiny.config.js diff --git a/packages/api-headless-cms-tasks/README.md b/packages/api-headless-cms-tasks/README.md deleted file mode 100644 index c266143a38c..00000000000 --- a/packages/api-headless-cms-tasks/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @webiny/api-headless-cms-tasks -[![](https://img.shields.io/npm/dw/@webiny/api-headless-cms-tasks.svg)](https://www.npmjs.com/package/@webiny/api-headless-cms-tasks) -[![](https://img.shields.io/npm/v/@webiny/api-headless-cms-tasks.svg)](https://www.npmjs.com/package/@webiny/api-headless-cms-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 -``` -npm install --save @webiny/api-headless-cms-tasks -``` - -Or if you prefer yarn: -``` -yarn add @webiny/api-headless-cms-tasks -``` diff --git a/packages/api-headless-cms-tasks/__tests__/context/useHandler.ts b/packages/api-headless-cms-tasks/__tests__/context/useHandler.ts deleted file mode 100644 index 6517fad58e5..00000000000 --- a/packages/api-headless-cms-tasks/__tests__/context/useHandler.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 { PluginCollection } from "@webiny/plugins/types"; -import { createBackgroundTaskContext } from "@webiny/tasks"; -import { HcmsTasksContext } from "~/types"; - -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 { - handler: async () => { - return await handler({}, {} as LambdaContext); - } - }; -}; diff --git a/packages/api-headless-cms-tasks/__tests__/tasks/deleteTrashBinEntries.test.ts b/packages/api-headless-cms-tasks/__tests__/tasks/deleteTrashBinEntries.test.ts deleted file mode 100644 index a16373f02ef..00000000000 --- a/packages/api-headless-cms-tasks/__tests__/tasks/deleteTrashBinEntries.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { createRunner } from "@webiny/project-utils/testing/tasks"; -import { ResponseDoneResult, ResponseErrorResult } from "@webiny/tasks"; -import { useHandler } from "~tests/context/useHandler"; -import { createMockModels } from "~tests/mocks"; -import { EntriesTask, HcmsTasksContext } from "~/types"; - -import { createDeleteTrashBinEntriesTask } from "~/tasks/entries/deleteTrashBinEntriesTask"; - -const createDeletedEntries = async (context: HcmsTasksContext, modelId: string, total = 100) => { - const model = await context.cms.getModel(modelId); - - if (!model) { - throw new Error("Error while retrieving the model"); - } - - for (let i = 0; i < total; i++) { - const entry = await context.cms.createEntry(model, { title: `Entry-${i}` }); - await context.cms.deleteEntry(model, entry.entryId, { - permanently: false - }); - } - - // Let's wait a little bit...we need the ES index to settle down. - await new Promise(res => setTimeout(res, 5000)); -}; - -const listDeletedEntries = async (context: HcmsTasksContext, modelId: string) => { - const model = await context.cms.getModel(modelId); - - if (!model) { - throw new Error("Error while retrieving the model"); - } - - const [entries, meta] = await context.cms.listDeletedEntries(model, { limit: 10000 }); - - return { - entries, - meta - }; -}; - -jest.setTimeout(100000); - -describe("Delete Trash Bin Entries", () => { - it("should fail in case of not existing model", async () => { - const taskDefinition = createDeleteTrashBinEntriesTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Delete Trash Bin Entries", - definitionId: taskDefinition.id, - input: { - modelId: "any-non-existing-modelId" - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseErrorResult); - - expect(result).toMatchObject({ - status: "error", - error: { - message: `Content model "any-non-existing-modelId" was not found!` - }, - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.DeleteTrashBinEntries, - tenant: "root", - locale: "en-US" - }); - }); - - it("should return success in case of no entries to delete", async () => { - const taskDefinition = createDeleteTrashBinEntriesTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const MODEL_ID = "car"; - - const task = await context.tasks.createTask({ - name: "Delete Trash Bin Entries", - definitionId: taskDefinition.id, - input: { - modelId: MODEL_ID - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - message: "Task done: No entries to delete.", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.DeleteTrashBinEntries, - tenant: "root", - locale: "en-US" - }); - }); - - it("should delete multiple entries", async () => { - const taskDefinition = createDeleteTrashBinEntriesTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const MODEL_ID = "car"; - const ENTRIES_COUNT = 50; - - await createDeletedEntries(context, MODEL_ID, ENTRIES_COUNT); - const { entries, meta } = await listDeletedEntries(context, MODEL_ID); - - // Let's save the entryIds - const entryIds = entries.map(entry => entry.entryId); - - // Let's check how many deleted entries we have been created - expect(meta.totalCount).toBe(ENTRIES_COUNT); - - const task = await context.tasks.createTask({ - name: "Delete Trash Bin Entries", - definitionId: taskDefinition.id, - input: { - modelId: MODEL_ID, - entryIds - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - // Let's check we just delete all the entries in the trash-bin - const entriesAfterDeleteResponse = await listDeletedEntries(context, MODEL_ID); - expect(entriesAfterDeleteResponse.meta.totalCount).toBe(0); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.DeleteTrashBinEntries, - tenant: "root", - locale: "en-US", - output: { - done: entryIds, - failed: [] - } - }); - }); -}); diff --git a/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBinByModel.test.ts b/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBinByModel.test.ts deleted file mode 100644 index 427693e4f7c..00000000000 --- a/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBinByModel.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { createRunner } from "@webiny/project-utils/testing/tasks"; -import { ResponseDoneResult, ResponseErrorResult } from "@webiny/tasks"; -import { useHandler } from "~tests/context/useHandler"; -import { createMockModels } from "~tests/mocks"; -import { EntriesTask, HcmsTasksContext } from "~/types"; - -import { createEmptyTrashBinByModelTask } from "~/tasks/entries/emptyTrashBinByModelTask"; - -const createDeletedEntries = async (context: HcmsTasksContext, modelId: string, total = 100) => { - const model = await context.cms.getModel(modelId); - - if (!model) { - throw new Error("Error while retrieving the model"); - } - - for (let i = 0; i < total; i++) { - const entry = await context.cms.createEntry(model, { title: `Entry-${i}` }); - await context.cms.deleteEntry(model, entry.entryId, { - permanently: false - }); - } -}; - -const listDeletedEntries = async (context: HcmsTasksContext, modelId: string) => { - const model = await context.cms.getModel(modelId); - - if (!model) { - throw new Error("Error while retrieving the model"); - } - - const [entries, meta] = await context.cms.listDeletedEntries(model, { limit: 10000 }); - - return { - entries, - meta - }; -}; - -jest.setTimeout(720000); - -describe("Empty Trash Bin By Model", () => { - it("should fail in case of invalid input - missing `modelId`", async () => { - const taskDefinition = createEmptyTrashBinByModelTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Empty Trash Bin By Model", - definitionId: taskDefinition.id, - input: {} - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseErrorResult); - - expect(result).toMatchObject({ - status: "error", - error: { - message: `Missing "modelId" in the input.` - }, - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBinByModel, - tenant: "root", - locale: "en-US" - }); - }); - - it("should fail in case of not existing model", async () => { - const taskDefinition = createEmptyTrashBinByModelTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Empty Trash Bin By Model", - definitionId: taskDefinition.id, - input: { - modelId: "any-non-existing-modelId" - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseErrorResult); - - expect(result).toMatchObject({ - status: "error", - error: { - message: `Content model "any-non-existing-modelId" was not found!` - }, - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBinByModel, - tenant: "root", - locale: "en-US" - }); - }); - - it("should return success in case of no entries to delete", async () => { - const taskDefinition = createEmptyTrashBinByModelTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const MODEL_ID = "car"; - - const task = await context.tasks.createTask({ - name: "Empty Trash Bin By Model", - definitionId: taskDefinition.id, - input: { - modelId: MODEL_ID - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - message: `Task done: no entries to delete for the "${MODEL_ID}" model.`, - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBinByModel, - tenant: "root", - locale: "en-US" - }); - }); - - // TODO: Add test for when multiple task definitions are supported. - it.skip("should delete entries in the trash bin", async () => { - const emptyTrashBinByModelTaskDefinition = createEmptyTrashBinByModelTask(); - - const { handler } = useHandler({ - plugins: [emptyTrashBinByModelTaskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const MODEL_ID = "car"; - const ENTRIES_COUNT = 200; - - await createDeletedEntries(context, MODEL_ID, ENTRIES_COUNT); - const { entries, meta } = await listDeletedEntries(context, MODEL_ID); - - // Let's check how many deleted entries we have been created - expect(meta.totalCount).toBe(ENTRIES_COUNT); - - const emptyTrashBinTask = await context.tasks.createTask({ - name: "Empty Trash Bin By Model", - definitionId: emptyTrashBinByModelTaskDefinition.id, - input: { - modelId: MODEL_ID - } - }); - - const runner = createRunner({ - context, - task: emptyTrashBinByModelTaskDefinition - }); - - console.time("run"); - - const result = await runner({ - webinyTaskId: emptyTrashBinTask.id - }); - - console.timeEnd("run"); - - // Let's check we just delete all the entries in the trash-bin - const entriesAfterDeleteResponse = await listDeletedEntries(context, MODEL_ID); - expect(entriesAfterDeleteResponse.meta.totalCount).toBe(0); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - webinyTaskId: emptyTrashBinByModelTaskDefinition.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBinByModel, - tenant: "root", - locale: "en-US", - output: { - done: entries, - failed: [] - } - }); - }); - - // TODO: Add test for when multiple task definitions are supported. - it.skip("should delete entries in the trash bin, with a custom `where` condition", async () => { - const taskDefinition = createEmptyTrashBinByModelTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createMockModels()] - }); - - const context = await handler(); - - const MODEL_ID = "car"; - const ENTRIES_COUNT = 10; - - await createDeletedEntries(context, MODEL_ID, ENTRIES_COUNT); - const { entries, meta } = await listDeletedEntries(context, MODEL_ID); - - // Let's check how many deleted entries we have been created - expect(meta.totalCount).toBe(ENTRIES_COUNT); - - const task = await context.tasks.createTask({ - name: "Empty Trash Bin By Model", - definitionId: taskDefinition.id, - input: { - modelId: MODEL_ID, - where: { - title: "Entry-0" - } - } - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - // Let's check we just delete all the entries in the trash-bin - const entriesAfterDeleteResponse = await listDeletedEntries(context, MODEL_ID); - expect(entriesAfterDeleteResponse.meta.totalCount).toBe(ENTRIES_COUNT - 1); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBinByModel, - tenant: "root", - locale: "en-US", - output: { - done: entries.filter(entry => entry.values.title === "Entry-0"), - failed: [] - } - }); - }); -}); diff --git a/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBins.test.ts b/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBins.test.ts deleted file mode 100644 index 07df7ce30ff..00000000000 --- a/packages/api-headless-cms-tasks/__tests__/tasks/emptyTrashBins.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useHandler } from "~tests/context/useHandler"; -import { EntriesTask, HcmsTasksContext } from "~/types"; -import { createMockModels, createPrivateMockModels } from "~tests/mocks"; -import { createRunner } from "@webiny/project-utils/testing/tasks"; -import { ResponseDoneResult } from "@webiny/tasks"; - -import { createEmptyTrashBinsTask } from "~/tasks/entries/emptyTrashBinsTask"; - -jest.setTimeout(100000); - -describe("Empty Trash Bins", () => { - it("should execute and return a `Done` response in case of no found models in the system", async () => { - const taskDefinition = createEmptyTrashBinsTask(); - const { handler } = useHandler({ - plugins: [taskDefinition] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Delete all trash bin entries", - definitionId: taskDefinition.id, - input: {} - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - message: "Task done: emptying the trash bin for all registered models.", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBins, - tenant: "root", - locale: "en-US" - }); - }); - - it("should execute and return a `Done` response in case of no public models found in the system", async () => { - const taskDefinition = createEmptyTrashBinsTask(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...createPrivateMockModels()] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Empty Trash Bins", - definitionId: taskDefinition.id, - input: {} - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - message: "Task done: emptying the trash bin for all registered models.", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBins, - tenant: "root", - locale: "en-US" - }); - }); - - // TODO: Add test for when multiple task definitions are supported. - it.skip("should execute and return a `Done` response in case of models registered", async () => { - const taskDefinition = createEmptyTrashBinsTask(); - const models = createMockModels(); - const { handler } = useHandler({ - plugins: [taskDefinition, ...models] - }); - - const context = await handler(); - - const task = await context.tasks.createTask({ - name: "Empty Trash Bins", - definitionId: taskDefinition.id, - input: {} - }); - - const runner = createRunner({ - context, - task: taskDefinition - }); - - const result = await runner({ - webinyTaskId: task.id - }); - - expect(result).toBeInstanceOf(ResponseDoneResult); - - expect(result).toMatchObject({ - status: "done", - message: "Task done: no public models found in the system.", - webinyTaskId: task.id, - webinyTaskDefinitionId: EntriesTask.EmptyTrashBins, - tenant: "root", - locale: "en-US" - }); - }); -}); diff --git a/packages/api-headless-cms-tasks/src/graphql.ts b/packages/api-headless-cms-tasks/src/graphql.ts deleted file mode 100644 index 4b00a4c25a4..00000000000 --- a/packages/api-headless-cms-tasks/src/graphql.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { CmsGraphQLSchemaPlugin, isHeadlessCmsReady } from "@webiny/api-headless-cms"; -import { ContextPlugin } from "@webiny/handler-aws"; -import { EntriesTask, HcmsTasksContext } from "~/types"; -import { Response } from "@webiny/handler-graphql"; - -export const createGraphQL = () => { - return new ContextPlugin(async context => { - if (!(await isHeadlessCmsReady(context))) { - return; - } - - const plugin = new CmsGraphQLSchemaPlugin({ - typeDefs: /* GraphQL */ ` - type EmptyTrashBinResponseData { - id: String - } - - type EmptyTrashBinResponse { - data: EmptyTrashBinResponseData - error: CmsError - } - - extend type Mutation { - emptyTrashBin(modelId: String!): EmptyTrashBinResponse - } - `, - resolvers: { - Mutation: { - emptyTrashBin: async (_, args) => { - const response = await context.tasks.trigger({ - definition: EntriesTask.EmptyTrashBinByModel, - input: { - modelId: args.modelId - } - }); - - return new Response({ - id: response.id - }); - } - } - } - }); - - plugin.name = "headless-cms.graphql.schema.trashBin.types"; - - context.plugins.register([plugin]); - }); -}; diff --git a/packages/api-headless-cms-tasks/src/index.ts b/packages/api-headless-cms-tasks/src/index.ts deleted file mode 100644 index 70ecd97575e..00000000000 --- a/packages/api-headless-cms-tasks/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createTasks } from "~/tasks"; -import { createHandlers } from "~/handlers"; - -export * from "./tasks/entries/useCases"; - -export const createHcmsTasks = () => [createTasks(), createHandlers()]; diff --git a/packages/api-headless-cms-tasks/src/tasks/common/index.ts b/packages/api-headless-cms-tasks/src/tasks/common/index.ts deleted file mode 100644 index 91dcf1ca3db..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useCases"; diff --git a/packages/api-headless-cms-tasks/src/tasks/common/useCases/index.ts b/packages/api-headless-cms-tasks/src/tasks/common/useCases/index.ts deleted file mode 100644 index 64b4b085d02..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/common/useCases/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ChildTasksCleanup"; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/deleteTrashBinEntriesTask.ts b/packages/api-headless-cms-tasks/src/tasks/entries/deleteTrashBinEntriesTask.ts deleted file mode 100644 index 756f46ce03f..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/deleteTrashBinEntriesTask.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createPrivateTaskDefinition } from "@webiny/tasks"; -import { ChildTasksCleanup } from "~/tasks/common"; -import { - EntriesTask, - HcmsTasksContext, - IDeleteTrashBinEntriesInput, - IDeleteTrashBinEntriesOutput -} from "~/types"; - -export const createDeleteTrashBinEntriesTask = () => { - return createPrivateTaskDefinition< - HcmsTasksContext, - IDeleteTrashBinEntriesInput, - IDeleteTrashBinEntriesOutput - >({ - id: EntriesTask.DeleteTrashBinEntries, - title: "Headless CMS - Delete trash bin entries", - description: "Delete trash bin entries.", - maxIterations: 2, - run: async params => { - const { response, isAborted } = params; - - try { - if (isAborted()) { - return response.aborted(); - } - - const { DeleteTrashBinEntries } = await import( - /* webpackChunkName: "DeleteTrashBinEntries" */ "~/tasks/entries/useCases/DeleteTrashBinEntries" - ); - - const deleteTrashBinEntries = new DeleteTrashBinEntries(); - return await deleteTrashBinEntries.execute(params); - } catch (ex) { - return response.error( - ex.message ?? "Error while executing DeleteTrashBinEntries task" - ); - } - }, - onDone: async ({ context, task }) => { - /** - * We want to clean all child tasks and logs, which have no errors. - */ - const childTasksCleanup = new ChildTasksCleanup(); - try { - await childTasksCleanup.execute({ - context, - task - }); - } catch (ex) { - console.error("Error while cleaning `DeleteTrashBinEntries` child tasks.", ex); - } - } - }); -}; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinByModelTask.ts b/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinByModelTask.ts deleted file mode 100644 index 49a6ba5f6be..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinByModelTask.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createPrivateTaskDefinition } from "@webiny/tasks"; -import { ChildTasksCleanup } from "~/tasks/common"; -import { - EntriesTask, - HcmsTasksContext, - IEmptyTrashBinByModelInput, - IEmptyTrashBinByModelOutput -} from "~/types"; - -export const createEmptyTrashBinByModelTask = () => { - return createPrivateTaskDefinition< - HcmsTasksContext, - IEmptyTrashBinByModelInput, - IEmptyTrashBinByModelOutput - >({ - id: EntriesTask.EmptyTrashBinByModel, - title: "Headless CMS - Empty trash bin by model", - description: "Delete all entries found in the trash bin, by model.", - maxIterations: 500, - run: async params => { - const { response, isAborted } = params; - - try { - if (isAborted()) { - return response.aborted(); - } - - const { EmptyTrashBinByModel } = await import( - /* webpackChunkName: "EmptyTrashBinByModel" */ "~/tasks/entries/useCases/EmptyTrashBinByModel" - ); - - const emptyTrashBinByModel = new EmptyTrashBinByModel(); - return await emptyTrashBinByModel.execute(params); - } catch (ex) { - return response.error( - ex.message ?? "Error while executing EmptyTrashBinByModel task" - ); - } - }, - onDone: async ({ context, task }) => { - /** - * We want to clean all child tasks and logs, which have no errors. - */ - const childTasksCleanup = new ChildTasksCleanup(); - try { - await childTasksCleanup.execute({ - context, - task - }); - } catch (ex) { - console.error("Error while cleaning `EmptyTrashBinByModel` child tasks.", ex); - } - } - }); -}; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinsTask.ts b/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinsTask.ts deleted file mode 100644 index 0a04a76eaa3..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/emptyTrashBinsTask.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createTaskDefinition } from "@webiny/tasks"; -import { ChildTasksCleanup } from "~/tasks/common"; -import { EntriesTask, HcmsTasksContext } from "~/types"; - -export const createEmptyTrashBinsTask = () => { - return createTaskDefinition({ - id: EntriesTask.EmptyTrashBins, - title: "Headless CMS - Empty all trash bins", - description: - "Delete all entries found in the trash bin, for each model found in the system.", - maxIterations: 1, - run: async params => { - const { response, isAborted } = params; - - try { - if (isAborted()) { - return response.aborted(); - } - - const { EmptyTrashBins } = await import( - /* webpackChunkName: "EmptyTrashBins" */ "~/tasks/entries/useCases/EmptyTrashBins" - ); - - const emptyTrashBins = new EmptyTrashBins(); - return await emptyTrashBins.execute(params); - } catch (ex) { - return response.error(ex.message ?? "Error while executing EmptyTrashBins task"); - } - }, - onDone: async ({ context, task }) => { - /** - * We want to clean all child tasks and logs, which have no errors. - */ - const childTasksCleanup = new ChildTasksCleanup(); - try { - await childTasksCleanup.execute({ - context, - task - }); - } catch (ex) { - console.error("Error while cleaning `EmptyTrashBins` child tasks.", ex); - } - } - }); -}; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/index.ts b/packages/api-headless-cms-tasks/src/tasks/entries/index.ts deleted file mode 100644 index 0d044a425b9..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createDeleteTrashBinEntriesTask } from "./deleteTrashBinEntriesTask"; -import { createEmptyTrashBinByModelTask } from "./emptyTrashBinByModelTask"; -import { createEmptyTrashBinsTask } from "./emptyTrashBinsTask"; - -export * from "./useCases"; - -export const createEntriesTasks = () => { - return [ - createDeleteTrashBinEntriesTask(), - createEmptyTrashBinByModelTask(), - createEmptyTrashBinsTask() - ]; -}; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/DeleteTrashBinEntries.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/DeleteTrashBinEntries.ts deleted file mode 100644 index fc3dc17fd90..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/DeleteTrashBinEntries.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ITaskResponseResult } from "@webiny/tasks"; -import { taskRepositoryFactory } from "./TaskRepositoryFactory"; -import { IDeleteTrashBinEntriesTaskParams } from "~/types"; -import { IUseCase } from "~/tasks/IUseCase"; - -export class DeleteTrashBinEntries - implements IUseCase -{ - public async execute(params: IDeleteTrashBinEntriesTaskParams) { - const { input, response, isAborted, isCloseToTimeout, context, store } = params; - - try { - if (isAborted()) { - return response.aborted(); - } else if (isCloseToTimeout()) { - return response.continue({ - ...input - }); - } - - if (!input.modelId) { - return response.error(`Missing "modelId" in the input.`); - } - - const model = await context.cms.getModel(input.modelId); - - if (!model) { - return response.error(`Model with ${input.modelId} not found!`); - } - - if (!input.entryIds || input.entryIds.length === 0) { - return response.done("Task done: No entries to delete."); - } - - const taskRepository = taskRepositoryFactory.getRepository(store.getTask().id); - - for (const entryId of input.entryIds) { - try { - await context.cms.deleteEntry(model, entryId, { - permanently: true - }); - taskRepository.addDone(entryId); - } catch (ex) { - const message = ex.message || `Failed to delete entry with entryId ${entryId}.`; - - try { - await store.addErrorLog({ - message, - error: ex - }); - } catch { - console.error(`Failed to add error log: "${message}"`); - } finally { - taskRepository.addFailed(entryId); - } - } - } - - return response.done("Task done.", { - done: taskRepository.getDone(), - failed: taskRepository.getFailed() - }); - } catch (ex) { - return response.error( - ex.message ?? - `Error while deleting entries found in the trash bin for model ${input.modelId}.` - ); - } - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepositoryFactory.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepositoryFactory.ts deleted file mode 100644 index f0b752583b3..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/TaskRepositoryFactory.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TaskRepository } from "./TaskRepository"; - -export class TaskRepositoryFactory { - private cache: Map = new Map(); - - getRepository(taskId: string) { - const cacheKey = this.getCacheKey(taskId); - - if (!this.cache.has(cacheKey)) { - this.cache.set(cacheKey, new TaskRepository()); - } - - return this.cache.get(cacheKey) as TaskRepository; - } - - private getCacheKey(taskId: string) { - return taskId; - } -} - -export const taskRepositoryFactory = new TaskRepositoryFactory(); diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/index.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/index.ts deleted file mode 100644 index 4626a0ff5d7..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/DeleteTrashBinEntries/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./DeleteTrashBinEntries"; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/CreateDeleteEntriesTasks.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/CreateDeleteEntriesTasks.ts deleted file mode 100644 index a1179277436..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/CreateDeleteEntriesTasks.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ITaskResponseResult } from "@webiny/tasks"; -import { CmsEntryListParams } from "@webiny/api-headless-cms/types"; -import { TaskCache } from "./TaskCache"; -import { TaskTrigger } from "./TaskTrigger"; -import { IEmptyTrashBinByModelTaskParams } from "~/types"; - -const BATCH_SIZE = 50; -const WAITING_TIME = 5; - -export class CreateDeleteEntriesTasks { - private taskCache = new TaskCache(); - private taskTrigger = new TaskTrigger(this.taskCache); - - public async execute(params: IEmptyTrashBinByModelTaskParams): Promise { - const { input, response, isAborted, isCloseToTimeout, context, store } = params; - - try { - if (!input.modelId) { - return response.error(`Missing "modelId" in the input.`); - } - - const model = await context.cms.getModel(input.modelId); - - if (!model) { - return response.error(`Model with ${input.modelId} not found!`); - } - - const listEntriesParams: CmsEntryListParams = { - where: input.where, - after: input.after, - limit: BATCH_SIZE - }; - - let currentBatch = input.currentBatch || 1; - - while (true) { - if (isAborted()) { - return response.aborted(); - } else if (isCloseToTimeout()) { - await this.taskTrigger.execute(context, store); - return response.continue({ - ...input, - ...listEntriesParams, - currentBatch - }); - } - - const [entries, meta] = await context.cms.listDeletedEntries( - model, - listEntriesParams - ); - - // If no entries exist for the provided query, let's return done. - if (meta.totalCount === 0) { - return response.done( - `Task done: no entries to delete for the "${input.modelId}" model.` - ); - } - - // If no entries are returned, let's trigger the cached child tasks and continue in `processing` mode. - if (entries.length === 0) { - await this.taskTrigger.execute(context, store); - return response.continue( - { - ...input, - ...listEntriesParams, - currentBatch, - totalCount: meta.totalCount, - processing: true - }, - { seconds: WAITING_TIME } - ); - } - - const entryIds = entries.map(entry => entry.id); - - if (entryIds.length > 0) { - this.taskCache.cacheTask(input.modelId, entryIds); - } - - // No more entries paginated, let's trigger the cached child tasks and continue in `processing` mode. - if (!meta.hasMoreItems || !meta.cursor) { - await this.taskTrigger.execute(context, store); - return response.continue( - { - ...input, - ...listEntriesParams, - currentBatch, - totalCount: meta.totalCount, - processing: true - }, - { seconds: WAITING_TIME } - ); - } - - listEntriesParams.after = meta.cursor; - currentBatch++; - } - } catch (ex) { - console.error("Error while executing CreateDeleteEntriesTasks:", ex); - return response.error(ex.message ?? "Error while executing CreateDeleteEntriesTasks"); - } - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/EmptyTrashBinByModel.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/EmptyTrashBinByModel.ts deleted file mode 100644 index aafddf21a6f..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/EmptyTrashBinByModel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ITaskResponseResult } from "@webiny/tasks"; -import { ProcessDeleteEntriesTasks } from "./ProcessDeleteEntriesTasks"; -import { CreateDeleteEntriesTasks } from "./CreateDeleteEntriesTasks"; -import { IEmptyTrashBinByModelTaskParams } from "~/types"; -import { IUseCase } from "~/tasks/IUseCase"; - -export class EmptyTrashBinByModel - implements IUseCase -{ - public async execute(params: IEmptyTrashBinByModelTaskParams) { - const { input, response } = params; - - try { - if (input.processing) { - const processDeleteEntriesTasks = new ProcessDeleteEntriesTasks(); - return await processDeleteEntriesTasks.execute(params); - } - - const createDeleteEntriesTasks = new CreateDeleteEntriesTasks(); - return await createDeleteEntriesTasks.execute(params); - } catch (ex) { - return response.error(ex.message ?? "Error while executing EmptyTrashBinByModel"); - } - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/ProcessDeleteEntriesTasks.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/ProcessDeleteEntriesTasks.ts deleted file mode 100644 index abb7e490b21..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/ProcessDeleteEntriesTasks.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ITaskResponseResult, TaskDataStatus } from "@webiny/tasks"; -import { EntriesTask, IEmptyTrashBinByModelTaskParams } from "~/types"; - -export const ZIP_PAGES_WAIT_TIME = 10; - -export class ProcessDeleteEntriesTasks { - public async execute(params: IEmptyTrashBinByModelTaskParams): Promise { - const { response, input, isAborted, isCloseToTimeout, context, store } = params; - - try { - if (isAborted()) { - return response.aborted(); - } else if (isCloseToTimeout()) { - return response.continue({ - ...input - }); - } - - const result = await context.tasks.listTasks({ - where: { - parentId: store.getTask().id, - definitionId: EntriesTask.DeleteTrashBinEntries, - taskStatus_in: [TaskDataStatus.RUNNING, TaskDataStatus.PENDING] - }, - limit: 1 - }); - - if (result.items.length > 0) { - return response.continue( - { - ...input - }, - { - seconds: ZIP_PAGES_WAIT_TIME - } - ); - } - - return response.done( - `Task done: trash bin for the "${input.modelId}" model has been emptied.` - ); - } catch (ex) { - return response.error(ex.message ?? "Error while executing ProcessDeleteEntriesTasks"); - } - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskCache.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskCache.ts deleted file mode 100644 index 5de9bfe59f5..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskCache.ts +++ /dev/null @@ -1,20 +0,0 @@ -interface TaskCacheItem { - modelId: string; - entryIds: string[]; -} - -export class TaskCache { - private taskCache: TaskCacheItem[] = []; - - cacheTask(modelId: string, entryIds: string[]): void { - this.taskCache.push({ modelId, entryIds }); - } - - getTasks(): TaskCacheItem[] { - return this.taskCache; - } - - clear(): void { - this.taskCache = []; - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskTrigger.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskTrigger.ts deleted file mode 100644 index 7bf442bb7bb..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/TaskTrigger.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ITaskManagerStore } from "@webiny/tasks"; -import { TaskCache } from "./TaskCache"; -import { - EntriesTask, - HcmsTasksContext, - IDeleteTrashBinEntriesInput, - IEmptyTrashBinByModelInput -} from "~/types"; - -export class TaskTrigger { - constructor(private taskCache: TaskCache) {} - - async execute(context: HcmsTasksContext, store: ITaskManagerStore) { - const tasks = this.taskCache.getTasks(); - if (tasks.length === 0) { - return; - } - - for (const task of tasks) { - try { - await context.tasks.trigger({ - definition: EntriesTask.DeleteTrashBinEntries, - name: `Headless CMS - Delete Entries - ${task.modelId}`, - parent: store.getTask(), - input: { - modelId: task.modelId, - entryIds: task.entryIds - } - }); - } catch (error) { - console.error(`Error triggering task for model ${task.modelId}:`, error); - } - } - - // Clear the cache after processing - this.taskCache.clear(); - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/index.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/index.ts deleted file mode 100644 index e51824424b8..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBinByModel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./EmptyTrashBinByModel"; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/EmptyTrashBins.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/EmptyTrashBins.ts deleted file mode 100644 index 7b9e47c20b4..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/EmptyTrashBins.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ITaskResponseResult } from "@webiny/tasks"; -import { EntriesTask, IEmptyTrashBinByModelInput, IEmptyTrashBins } from "~/types"; -import { IUseCase } from "~/tasks/IUseCase"; - -export class EmptyTrashBins implements IUseCase { - public async execute(params: IEmptyTrashBins) { - const { response, isAborted, context } = params; - - try { - if (isAborted()) { - return response.aborted(); - } - - const locales = context.i18n.getLocales(); - - await context.i18n.withEachLocale(locales, async () => { - const models = await context.security.withoutAuthorization(async () => { - return (await context.cms.listModels()).filter(model => !model.isPrivate); - }); - - for (const model of models) { - await context.tasks.trigger({ - name: `Headless CMS - Empty trash bin for "${model.name}" model.`, - definition: EntriesTask.EmptyTrashBinByModel, - parent: params.store.getTask(), - input: { - modelId: model.modelId, - where: { - deletedOn_lt: this.calculateDateTimeString() - } - } - }); - } - return; - }); - - return response.done(`Task done: emptying the trash bin for all registered models.`); - } catch (ex) { - return response.error(ex.message ?? "Error while executing EmptyTrashBins"); - } - } - - private calculateDateTimeString() { - // Retrieve the retention period from the environment variable WEBINY_TRASH_BIN_RETENTION_PERIOD_DAYS, - // or default to 90 days if not set or set to 0. - const retentionPeriodFromEnv = process.env["WEBINY_TRASH_BIN_RETENTION_PERIOD_DAYS"]; - const retentionPeriod = - retentionPeriodFromEnv && Number(retentionPeriodFromEnv) !== 0 - ? Number(retentionPeriodFromEnv) - : 90; - - // Calculate the date-time by subtracting the retention period (in days) from the current date. - const currentDate = new Date(); - currentDate.setDate(currentDate.getDate() - retentionPeriod); - - // Return the calculated date-time string in ISO 8601 format. - return currentDate.toISOString(); - } -} diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/index.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/index.ts deleted file mode 100644 index 165381f7375..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/EmptyTrashBins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./EmptyTrashBins"; diff --git a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/index.ts b/packages/api-headless-cms-tasks/src/tasks/entries/useCases/index.ts deleted file mode 100644 index a1e59c62fc4..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/entries/useCases/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./DeleteTrashBinEntries"; -export * from "./EmptyTrashBinByModel"; -export * from "./EmptyTrashBins"; diff --git a/packages/api-headless-cms-tasks/src/tasks/index.ts b/packages/api-headless-cms-tasks/src/tasks/index.ts deleted file mode 100644 index 05f99d6d060..00000000000 --- a/packages/api-headless-cms-tasks/src/tasks/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createEntriesTasks } from "./entries"; - -export const createTasks = () => { - return [createEntriesTasks()]; -}; diff --git a/packages/api-headless-cms-tasks/src/types.ts b/packages/api-headless-cms-tasks/src/types.ts deleted file mode 100644 index bc8e4fe307e..00000000000 --- a/packages/api-headless-cms-tasks/src/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { CmsContext } from "@webiny/api-headless-cms/types"; -import { Context as BaseContext } from "@webiny/handler/types"; -import { - Context as TasksContext, - ITaskResponseDoneResultOutput, - ITaskRunParams -} from "@webiny/tasks/types"; - -export interface HcmsTasksContext extends BaseContext, CmsContext, TasksContext {} - -export enum EntriesTask { - EmptyTrashBins = "hcmsEntriesEmptyTrashBins", - EmptyTrashBinByModel = "hcmsEntriesEmptyTrashBinByModel", - DeleteTrashBinEntries = "hcmsEntriesDeleteTrashBinEntries" -} - -/** - * Empty Trash Bins - */ - -export type IEmptyTrashBins = ITaskRunParams; - -/** - * Empty Trash Bin by Model - */ - -export interface IEmptyTrashBinByModelInput { - modelId: string; - where?: Record; - after?: string | null; - currentBatch?: number; - processing?: boolean; - totalCount?: number; -} - -export interface IEmptyTrashBinByModelOutput extends ITaskResponseDoneResultOutput { - done: string[]; - failed: string[]; -} - -export type IEmptyTrashBinByModelTaskParams = ITaskRunParams< - HcmsTasksContext, - IEmptyTrashBinByModelInput, - IEmptyTrashBinByModelOutput ->; - -/** - * Delete Trash Bin Entries - */ - -export interface IDeleteTrashBinEntriesInput { - modelId: string; - entryIds: string[]; - done?: string[]; - failed?: string[]; -} - -export interface IDeleteTrashBinEntriesOutput extends ITaskResponseDoneResultOutput { - done: string[]; - failed: string[]; -} - -export type IDeleteTrashBinEntriesTaskParams = ITaskRunParams< - HcmsTasksContext, - IDeleteTrashBinEntriesInput, - IDeleteTrashBinEntriesOutput ->; diff --git a/packages/app-aco/src/contexts/acoList.tsx b/packages/app-aco/src/contexts/acoList.tsx index 6b114ffa6a9..fc68a7ae6eb 100644 --- a/packages/app-aco/src/contexts/acoList.tsx +++ b/packages/app-aco/src/contexts/acoList.tsx @@ -34,6 +34,12 @@ export interface AcoListContextData { setSelected: (selected: T[]) => void; showFilters: () => void; showingFilters: boolean; + showingSelectAll: boolean; + searchQuery: string; + isSelectedAll: boolean; + selectAll: () => void; + unselectAll: () => void; + getWhere: () => Record; } export const AcoListContext = React.createContext< @@ -50,6 +56,8 @@ export interface State { searchQuery: string; selected: T[]; showingFilters: boolean; + showingSelectAll: boolean; + isSelectedAll: boolean; } const initializeAcoListState = (): State => { @@ -62,7 +70,9 @@ const initializeAcoListState = (): State => { listSort: [], searchQuery: "", selected: [], - showingFilters: false + showingFilters: false, + showingSelectAll: false, + isSelectedAll: false }; }; @@ -158,7 +168,9 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => isSearch: false, searchQuery: "", selected: [], - showingFilters: false + showingFilters: false, + showingSelectAll: false, + isSelectedAll: false }; }); }, [currentFolderId]); @@ -229,6 +241,36 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => } }, [meta]); + /** + * Constructs a "where" condition object based on the current state and properties. + * + * This function creates a "where" object used to filter data based on the current folder ID, + * ownership status, and other existing filters in the state. + * + * @returns {Object} A "where" condition object containing filters for querying data. + */ + const getWhere = useCallback(() => { + // Initialize an empty object + let where = {}; + + // Check if the current folder ID is not the ROOT_FOLDER folder + if (state.folderId !== ROOT_FOLDER) { + // Get descendant folder IDs of the current folder + const descendantFolderIds = getDescendantFolders(state.folderId).map( + folder => folder.id + ); + + // Set the locationWhere object with descendant folder IDs + where = dotPropImmutable.set({}, folderIdInPath, descendantFolderIds); + } + + return { + createdBy: props.own ? identity?.id : undefined, // Set 'createdBy' based on the ownership status + ...state.filters, // Merge existing filters into the 'where' condition + ...where // Include where condition if applicable + }; + }, [folders, state.folderId, state.filters, props.own, identity]); + /** * Any time we receive new useful `state` params: * - we fetch records according to the new params @@ -244,16 +286,13 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => (state.filters && Object.values(state.filters).filter(Boolean).length) ); - let locationWhere = dotPropImmutable.set({}, folderIdPath, state.folderId); + let where = dotPropImmutable.set({}, folderIdPath, state.folderId); if (isSearch) { if (state.folderId === ROOT_FOLDER) { - locationWhere = undefined; + where = undefined; } else { - const descendantFolderIds = getDescendantFolders(state.folderId).map( - folder => folder.id - ); - locationWhere = dotPropImmutable.set({}, folderIdInPath, descendantFolderIds); + where = getWhere(); } } @@ -262,11 +301,7 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => sort: validateOrGetDefaultDbSort(state.listSort), search: state.searchQuery, after: state.after, - where: { - createdBy: props.own ? identity!.id : undefined, - ...locationWhere, - ...state.filters - } + where }; await listRecords(params); @@ -275,10 +310,90 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => }; listItems(); - }, [state.folderId, state.filters, state.searchQuery, state.after, state.listSort]); + }, [ + state.folderId, + state.filters, + state.searchQuery, + state.after, + state.listSort, + state.limit, + props.own, + identity + ]); + + /** + * useEffect hook to determine if the "Select All" option should be displayed based on the current state and meta properties: + * - if in the root folder with no folders, checks if all records are selected. + * - if in a non-root folder with multiple descendant folders, checks if all records are selected. + * - if there are more items to load, checks if all records are selected. + */ + useEffect(() => { + // Destructure relevant properties from state and meta + const { selected, folderId } = state; + const { hasMoreItems } = meta; + + // Retrieve all descendant folders of the current folderId + const folderWithChildren = getDescendantFolders(folderId); + + // Compute the lengths of various arrays for later comparisons + const foldersLength = folders.length; + const recordsLength = records.length; + const selectedLength = selected.length; + const folderWithChildrenLength = folderWithChildren.length; + + // Function to determine if all records are selected + const getAllRecordsAreSelected = () => !!recordsLength && recordsLength === selectedLength; + + // Initialize a flag to determine if the "Select All" option should be shown + let showingSelectAll = false; + + // If in the root folder and there are some folders, check if all records are selected + if (folderId === ROOT_FOLDER && foldersLength > 0) { + showingSelectAll = getAllRecordsAreSelected(); + } + + // If not in the root folder and there are multiple descendant folders, check if all records are selected + if (folderId !== ROOT_FOLDER && folderWithChildrenLength > 1) { + showingSelectAll = getAllRecordsAreSelected(); + } + + // If there are more items to load, check if all records are selected + if (hasMoreItems) { + showingSelectAll = getAllRecordsAreSelected(); + } + + // Update the component's state based on the computed showingSelectAll flag + setState(prevState => { + // Only update if there is a change in showingSelectAll or if isSelectedAll was true previously + if (!prevState.isSelectedAll && prevState.showingSelectAll === showingSelectAll) { + return prevState; + } + + // Return the new state with updated showingSelectAll and reset isSelectedAll to false + return { + ...prevState, + isSelectedAll: false, + showingSelectAll + }; + }); + }, [ + records.length, + folders.length, + state.isSearch, + meta.hasMoreItems, + state.selected.length, + state.folderId + ]); const context: AcoListContextData = { - ...pick(state, ["isSearch", "selected", "showingFilters"]), + ...pick(state, [ + "isSearch", + "searchQuery", + "selected", + "showingFilters", + "showingSelectAll", + "isSelectedAll" + ]), folders, records, listTitle, @@ -320,6 +435,17 @@ export const AcoListProvider = ({ children, ...props }: AcoListProviderProps) => showFilters() { setState(state => ({ ...state, showingFilters: true })); }, + selectAll() { + setState(state => ({ ...state, isSelectedAll: true })); + }, + unselectAll() { + setState(state => ({ + ...state, + selected: [], + isSelectedAll: false + })); + }, + getWhere, listMoreRecords }; diff --git a/packages/app-admin/src/styles/material-theme-assignments.scss b/packages/app-admin/src/styles/material-theme-assignments.scss index 04a0312adf0..89c716bbf08 100644 --- a/packages/app-admin/src/styles/material-theme-assignments.scss +++ b/packages/app-admin/src/styles/material-theme-assignments.scss @@ -357,6 +357,12 @@ Fix the width of input components when inside grids } } +.mdc-snackbar__actions { + .mdc-icon-button { + color: var(--mdc-theme-text-primary-on-dark); + } +} + // when grid is inside another grid cell, child grid should not have any padding .mdc-layout-grid { > .mdc-layout-grid__inner { diff --git a/packages/app-headless-cms-common/src/entries.graphql.ts b/packages/app-headless-cms-common/src/entries.graphql.ts index 41c38d5a97c..bfd960a4174 100644 --- a/packages/app-headless-cms-common/src/entries.graphql.ts +++ b/packages/app-headless-cms-common/src/entries.graphql.ts @@ -489,3 +489,39 @@ export const createUnpublishMutation = (model: CmsEditorContentModel) => { } }`; }; + +/** + * ############################################ + * Bulk Action Mutation + */ +export interface CmsEntryBulkActionMutationResponse { + content: { + data?: { + id: string; + }; + error?: CmsErrorResponse; + }; +} + +export interface CmsEntryBulkActionMutationVariables { + action: string; + where?: { + [key: string]: any; + }; + search?: string; + data?: { + [key: string]: any; + }; +} + +export const createBulkActionMutation = (model: CmsEditorContentModel) => { + return gql` + mutation CmsBulkAction${model.singularApiName}($action: BulkAction${model.singularApiName}Name!, $where: ${model.singularApiName}ListWhereInput, $search: String, $data: JSON) { + content: bulkAction${model.singularApiName}(action: $action, where: $where, search: $search, data: $data) { + data { + id + } + error ${ERROR_FIELD} + } + }`; +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx index a4f2e477c72..6f133ac7bf8 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from "react"; +import React from "react"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; import { observer } from "mobx-react-lite"; import { parseIdentifier } from "@webiny/utils/parseIdentifier"; import { useRecords } from "@webiny/app-aco"; +import { useSnackbar } from "@webiny/app-admin"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; import { useCms, useModel } from "~/admin/hooks"; import { getEntriesLabel } from "~/admin/components/ContentEntries/BulkActions/BulkActions"; @@ -11,15 +12,14 @@ export const ActionDelete = observer(() => { const { model } = useModel(); const { deleteEntry } = useCms(); const { removeRecordFromCache } = useRecords(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = ContentEntryListConfig.Browser.BulkAction; const { IconButton } = useButtons(); const worker = useWorker(); const { showConfirmationDialog, showResultsDialog } = useDialog(); - const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + const entriesLabel = getEntriesLabel(); const openDeleteEntriesDialog = () => showConfirmationDialog({ @@ -27,6 +27,19 @@ export const ActionDelete = observer(() => { message: `You are about to move ${entriesLabel} to trash. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk("moveToTrash"); + worker.resetItems(); + showSnackbar( + "All entries will be moved to trash. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.", + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { /** diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx index 61cd5ff914e..366a0edd4bc 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback } from "react"; import { ReactComponent as MoveIcon } from "@material-design-icons/svg/outlined/drive_file_move.svg"; import { useRecords, useMoveToFolderDialog, useNavigateFolder } from "@webiny/app-aco"; +import { useSnackbar } from "@webiny/app-admin"; import { FolderItem } from "@webiny/app-aco/types"; import { observer } from "mobx-react-lite"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; @@ -10,6 +11,7 @@ import { getEntriesLabel } from "~/admin/components/ContentEntries/BulkActions/B export const ActionMove = observer(() => { const { moveRecord } = useRecords(); const { currentFolderId } = useNavigateFolder(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = ContentEntryListConfig.Browser.BulkAction; const { IconButton } = useButtons(); @@ -17,9 +19,7 @@ export const ActionMove = observer(() => { const { showConfirmationDialog, showResultsDialog } = useDialog(); const { showDialog: showMoveDialog } = useMoveToFolderDialog(); - const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + const entriesLabel = getEntriesLabel(); const openWorkerDialog = useCallback( (folder: FolderItem) => { @@ -28,6 +28,21 @@ export const ActionMove = observer(() => { message: `You are about to move ${entriesLabel} to ${folder.title}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk("moveToFolder", { + folderId: folder.id + }); + worker.resetItems(); + showSnackbar( + `All entries will be moved to ${folder.title}. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.`, + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { await moveRecord({ @@ -59,7 +74,7 @@ export const ActionMove = observer(() => { } }); }, - [entriesLabel] + [entriesLabel, worker.isSelectedAll] ); const openMoveEntriesDialog = () => diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx index 0888003a6fa..2df345eb7ee 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx @@ -1,25 +1,25 @@ -import React, { useMemo } from "react"; +import React from "react"; import { ReactComponent as PublishIcon } from "@material-design-icons/svg/outlined/publish.svg"; import { observer } from "mobx-react-lite"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; import { usePermission, useCms, useModel } from "~/admin/hooks"; import { getEntriesLabel } from "~/admin/components/ContentEntries/BulkActions/BulkActions"; import { useRecords } from "@webiny/app-aco"; +import { useSnackbar } from "@webiny/app-admin"; export const ActionPublish = observer(() => { const { model } = useModel(); const { canPublish } = usePermission(); const { publishEntryRevision } = useCms(); const { updateRecordInCache } = useRecords(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = ContentEntryListConfig.Browser.BulkAction; const { IconButton } = useButtons(); const worker = useWorker(); const { showConfirmationDialog, showResultsDialog } = useDialog(); - const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + const entriesLabel = getEntriesLabel(); const openPublishEntriesDialog = () => showConfirmationDialog({ @@ -27,6 +27,19 @@ export const ActionPublish = observer(() => { message: `You are about to publish ${entriesLabel}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk("publish"); + worker.resetItems(); + showSnackbar( + "All entries will be published. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.", + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { const response = await publishEntryRevision({ model, id: item.id }); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx index 974875cef8a..daab7e96c91 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx @@ -1,25 +1,25 @@ -import React, { useMemo } from "react"; +import React from "react"; import { ReactComponent as UnpublishIcon } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; import { observer } from "mobx-react-lite"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; import { useCms, useModel, usePermission } from "~/admin/hooks"; import { getEntriesLabel } from "~/admin/components/ContentEntries/BulkActions/BulkActions"; import { useRecords } from "@webiny/app-aco"; +import { useSnackbar } from "@webiny/app-admin"; export const ActionUnpublish = observer(() => { const { model } = useModel(); const { canUnpublish } = usePermission(); const { unpublishEntryRevision } = useCms(); const { updateRecordInCache } = useRecords(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = ContentEntryListConfig.Browser.BulkAction; const { IconButton } = useButtons(); const worker = useWorker(); const { showConfirmationDialog, showResultsDialog } = useDialog(); - const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + const entriesLabel = getEntriesLabel(); const openUnpublishEntriesDialog = () => showConfirmationDialog({ @@ -27,6 +27,19 @@ export const ActionUnpublish = observer(() => { message: `You are about to unpublish ${entriesLabel}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk("unpublish"); + worker.resetItems(); + showSnackbar( + "All entries will be unpublished. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.", + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { const response = await unpublishEntryRevision({ diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/BulkActions.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/BulkActions.tsx index 5e2d8012804..de39e36e4e3 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/BulkActions.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/BulkActions.tsx @@ -12,19 +12,29 @@ import { i18n } from "@webiny/app/i18n"; const t = i18n.ns("app-headless-cms/admin/content-entries/bulk-actions"); -export const getEntriesLabel = (count = 0): string => { - return `${count} ${count === 1 ? "entry" : "entries"}`; +export const getEntriesLabel = (): string => { + const { selected, isSelectedAll } = useContentEntriesList(); + + if (isSelectedAll) { + return "all entries"; + } + + return `${selected.length} ${selected.length === 1 ? "entry" : "entries"}`; }; export const BulkActions = () => { const { browser } = useContentEntryListConfig(); - const { selected, setSelected } = useContentEntriesList(); + const { selected, setSelected, isSelectedAll } = useContentEntriesList(); const headline = useMemo((): string => { + if (isSelectedAll) { + return t("All entries selected:"); + } + return t`{count|count:1:entry:default:entries} selected:`({ count: selected.length }); - }, [selected]); + }, [selected.length, isSelectedAll]); if (!selected.length) { return null; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/Messages.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/Messages.tsx new file mode 100644 index 00000000000..2ba98615056 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/Messages.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Typography } from "@webiny/ui/Typography"; +import { getEntriesLabel } from "./SelectAll"; +import { Button, MessageContainer } from "./SelectAll.styled"; + +export interface MessagesProps { + onClick: () => void; + selected: number; +} + +export const SelectAllMessage = (props: MessagesProps) => { + return ( + + {`${getEntriesLabel(props.selected)} selected.`} + + + ); +}; + +export const ClearSelectionMessage = (props: MessagesProps) => { + return ( + + {`All entries are selected.`} + + + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.styled.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.styled.tsx new file mode 100644 index 00000000000..bb1889b79e5 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.styled.tsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; +import { ButtonDefault } from "@webiny/ui/Button"; + +export const SelectAllContainer = styled.div` + width: 100%; + height: auto; + background-color: var(--mdc-theme-background); + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + padding: 16px; + text-align: center; +`; + +export const MessageContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +export const Button = styled(ButtonDefault)` + margin-left: 8px; +`; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.tsx new file mode 100644 index 00000000000..c71b8ba42c1 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/SelectAll.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { useContentEntriesList } from "~/admin/views/contentEntries/hooks"; +import { SelectAllContainer } from "./SelectAll.styled"; +import { ClearSelectionMessage, SelectAllMessage } from "./Messages"; + +export const getEntriesLabel = (count = 0): string => { + return `${count} ${count === 1 ? "entry" : "entries"}`; +}; + +export const SelectAll = () => { + const list = useContentEntriesList(); + + if (!list.showingSelectAll) { + return null; + } + + return ( + + {list.isSelectedAll ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/index.ts b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/index.ts new file mode 100644 index 00000000000..72c9bbafe39 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/SelectAll/index.ts @@ -0,0 +1 @@ +export * from "./SelectAll"; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/TrashBinBulkActionsGraphQLGateway.ts b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/TrashBinBulkActionsGraphQLGateway.ts new file mode 100644 index 00000000000..b6234af1d81 --- /dev/null +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/TrashBinBulkActionsGraphQLGateway.ts @@ -0,0 +1,43 @@ +import { ApolloClient } from "apollo-client"; +import { ITrashBinBulkActionsGateway } from "@webiny/app-trash-bin"; +import { + CmsEntryBulkActionMutationResponse, + CmsEntryBulkActionMutationVariables, + createBulkActionMutation +} from "@webiny/app-headless-cms-common"; +import { CmsModel } from "@webiny/app-headless-cms-common/types"; +import { TrashBinBulkActionsGatewayParams } from "@webiny/app-trash-bin/types"; + +export class TrashBinBulkActionsGraphQLGateway implements ITrashBinBulkActionsGateway { + private client: ApolloClient; + private model: CmsModel; + + constructor(client: ApolloClient, model: CmsModel) { + this.client = client; + this.model = model; + } + + async execute(params: TrashBinBulkActionsGatewayParams) { + const { data: response } = await this.client.mutate< + CmsEntryBulkActionMutationResponse, + CmsEntryBulkActionMutationVariables + >({ + mutation: createBulkActionMutation(this.model), + variables: { + ...params + } + }); + + if (!response) { + throw new Error("Network error while performing a bulk action."); + } + + const { data, error } = response.content; + + if (!data) { + throw new Error(error?.message || "Could not perform the bulk action."); + } + + return data; + } +} diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/index.ts b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/index.ts index a4539b7ef1f..2938ed60ac8 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/index.ts +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/adapters/index.ts @@ -1,3 +1,4 @@ +export * from "./TrashBinBulkActionsGraphQLGateway"; export * from "./TrashBinListGraphQLGateway"; export * from "./TrashBinDeleteItemGraphQLGateway"; export * from "./TrashBinRestoreItemGraphQLGateway"; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/components/TrashBin.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/components/TrashBin.tsx index 3280246885c..8f66f6c1ef7 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/components/TrashBin.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/TrashBin/components/TrashBin.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from "react"; import { useApolloClient, useModel, usePermission } from "~/admin/hooks"; import { TrashBin as BaseTrashBin } from "@webiny/app-trash-bin"; import { + TrashBinBulkActionsGraphQLGateway, TrashBinDeleteItemGraphQLGateway, TrashBinItemMapper, TrashBinListGraphQLGateway, @@ -32,6 +33,10 @@ export const TrashBin = () => { return new TrashBinRestoreItemGraphQLGatewayWithCallback(getRecord, restoreGateway); }, [client, model]); + const bulkActionsGateway = useMemo(() => { + return new TrashBinBulkActionsGraphQLGateway(client, model); + }, [client, model]); + const itemMapper = useMemo(() => { return new TrashBinItemMapper(); }, []); @@ -52,6 +57,9 @@ export const TrashBin = () => { render={({ showTrashBin }) => { return ; }} + bulkActionsGateway={bulkActionsGateway} + deleteBulkActionName={"delete"} + restoreBulkActionName={"restore"} listGateway={listGateway} deleteGateway={deleteGateway} restoreGateway={restoreGateway} diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/BulkAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/BulkAction.tsx index 7c44fd732d3..6301866769a 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/BulkAction.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/BulkAction.tsx @@ -7,7 +7,7 @@ import { Worker } from "@webiny/app-admin"; import { Property, useIdGenerator } from "@webiny/react-properties"; -import { useContentEntriesList, useModel } from "~/admin/hooks"; +import { useCms, useContentEntriesList, useModel } from "~/admin/hooks"; import { CmsContentEntry } from "@webiny/app-headless-cms-common/types"; export interface BulkActionConfig { @@ -65,7 +65,9 @@ export const BaseBulkAction = makeDecoratable( ); const useWorker = () => { - const { selected, setSelected } = useContentEntriesList(); + const { model } = useModel(); + const { selected, setSelected, getWhere, isSelectedAll } = useContentEntriesList(); + const { bulkAction } = useCms(); const { current: worker } = useRef(new Worker()); useEffect(() => { @@ -89,8 +91,12 @@ const useWorker = () => { }: CallbackParams) => Promise, chunkSize?: number ) => worker.processInSeries(callback, chunkSize), + processInBulk: async (action: string, data?: Record) => { + await bulkAction({ model, action, where: getWhere(), data }); + }, resetItems: resetItems, - results: worker.results + results: worker.results, + isSelectedAll }; }; diff --git a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx index 322610e7f88..46cc3df2c8e 100644 --- a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx +++ b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx @@ -25,7 +25,10 @@ import { CmsEntryCreateFromMutationResponse, CmsEntryCreateFromMutationVariables, CmsEntryGetQueryResponse, - CmsEntryGetQueryVariables + CmsEntryGetQueryVariables, + createBulkActionMutation, + CmsEntryBulkActionMutationResponse, + CmsEntryBulkActionMutationVariables } from "@webiny/app-headless-cms-common"; import { getFetchPolicy } from "~/utils/getFetchPolicy"; @@ -40,6 +43,11 @@ interface OperationSuccess { error?: never; } +interface BulkActionOperationSuccess { + id: string; + error?: never; +} + interface OperationError { entry?: never; error: EntryError; @@ -53,6 +61,7 @@ export type UpdateEntryRevisionResponse = OperationSuccess | OperationError; export type DeleteEntryResponse = boolean | OperationError; export type PublishEntryRevisionResponse = OperationSuccess | OperationError; export type UnpublishEntryRevisionResponse = OperationSuccess | OperationError; +export type BulkActionResponse = BulkActionOperationSuccess | OperationError; export interface CreateEntryParams { model: CmsModel; @@ -98,6 +107,13 @@ export interface GetEntryParams { id: string; } +export interface BulkActionParams { + model: CmsModel; + action: string; + where?: Record; + data?: Record; +} + export interface CmsContext { getApolloClient(locale: string): ApolloClient; createApolloClient: CmsProviderProps["createApolloClient"]; @@ -117,6 +133,7 @@ export interface CmsContext { params: UnpublishEntryRevisionParams ) => Promise; deleteEntry: (params: DeleteEntryParams) => Promise; + bulkAction: (params: BulkActionParams) => Promise; } export const CmsContext = React.createContext(undefined); @@ -392,6 +409,49 @@ export const CmsProvider = (props: CmsProviderProps) => { } return true; + }, + bulkAction: async ({ model, action, where, data }) => { + const mutation = createBulkActionMutation(model); + const response = await value.apolloClient.mutate< + CmsEntryBulkActionMutationResponse, + CmsEntryBulkActionMutationVariables + >({ + mutation, + variables: { + action, + where, + data + } + }); + + if (!response.data) { + return { + error: { + message: "Missing response data on Bulk Action mutation.", + code: "MISSING_RESPONSE_DATA", + data: {} + } + }; + } + const { data: responseData, error } = response.data.content; + + if (error) { + return { + error + }; + } + + if (!responseData) { + return { + error: { + message: "Missing response data on Bulk Action mutation.", + code: "MISSING_RESPONSE_DATA", + data: {} + } + }; + } + + return responseData; } }; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/Table/Main.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/Table/Main.tsx index 7f1c6d666df..af6797906a2 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/Table/Main.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/Table/Main.tsx @@ -14,6 +14,7 @@ import { ContentEntry } from "~/admin/views/contentEntries/ContentEntry"; import { useRouter } from "@webiny/react-router"; import { ROOT_FOLDER } from "~/admin/constants"; import { BulkActions } from "~/admin/components/ContentEntries/BulkActions"; +import { SelectAll } from "~/admin/components/ContentEntries/SelectAll"; interface MainProps { folderId?: string; @@ -85,6 +86,7 @@ export const Main = ({ folderId: initialFolderId }: MainProps) => { /> + {list.records.length === 0 && list.folders.length === 0 && 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 f4d6b5f5a62..c9139355ff6 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 @@ -46,8 +46,14 @@ export interface ContentEntriesListProviderContext { setSorting: OnSortingChange; showFilters: () => void; showingFilters: boolean; + showingSelectAll: boolean; sorting: Sorting; setFilters: (data: Record) => void; + selectAll: () => void; + unselectAll: () => void; + isSelectedAll: boolean; + getWhere: () => Record; + searchQuery: string; } export const ContentEntriesListContext = React.createContext< @@ -79,7 +85,12 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi setSelected, showFilters, hideFilters, - showingFilters + showingFilters, + showingSelectAll, + isSelectedAll, + selectAll, + unselectAll, + getWhere } = useAcoList(); const [sorting, setSorting] = useState([]); @@ -197,7 +208,13 @@ export const ContentEntriesListProvider = ({ children }: ContentEntriesListProvi showingFilters, showFilters, hideFilters, - setFilters + setFilters, + showingSelectAll, + isSelectedAll, + selectAll, + unselectAll, + getWhere, + searchQuery }; return ( diff --git a/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/ISelectedItemsRepository.ts b/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/ISelectedItemsRepository.ts index 2a8d73a2f3f..6922c4e7807 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/ISelectedItemsRepository.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/ISelectedItemsRepository.ts @@ -2,5 +2,8 @@ import { TrashBinItem } from "~/Domain"; export interface ISelectedItemsRepository { selectItems: (items: TrashBinItem[]) => Promise; + selectAllItems: () => Promise; + unselectAllItems: () => Promise; getSelectedItems: () => TrashBinItem[]; + getSelectedAllItems: () => boolean; } diff --git a/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/SelectedItemsRepository.ts b/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/SelectedItemsRepository.ts index ec79a13f26c..514fb202ee9 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/SelectedItemsRepository.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/SelectedItems/SelectedItemsRepository.ts @@ -4,6 +4,7 @@ import { ISelectedItemsRepository } from "./ISelectedItemsRepository"; export class SelectedItemsRepository implements ISelectedItemsRepository { private items: TrashBinItem[] = []; + private selectedAll = false; constructor() { makeAutoObservable(this); @@ -13,7 +14,21 @@ export class SelectedItemsRepository implements ISelectedItemsRepository { return this.items; } + getSelectedAllItems() { + return this.selectedAll; + } + async selectItems(items: TrashBinItem[]) { this.items = items; + this.selectedAll = false; + } + + async selectAllItems() { + this.selectedAll = true; + } + + async unselectAllItems() { + this.items = []; + this.selectedAll = false; } } diff --git a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/ITrashBinItemsRepository.ts b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/ITrashBinItemsRepository.ts index 85298f54270..0c43aab647a 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/ITrashBinItemsRepository.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/ITrashBinItemsRepository.ts @@ -1,12 +1,13 @@ import { Meta } from "@webiny/app-utils"; import { TrashBinItem } from "~/Domain"; -import { TrashBinListQueryVariables } from "~/types"; +import { TrashBinBulkActionsParams, TrashBinListQueryVariables } from "~/types"; export interface ITrashBinItemsRepository { listItems: (params?: TrashBinListQueryVariables) => Promise; listMoreItems: () => Promise; deleteItem: (id: string) => Promise; restoreItem: (id: string) => Promise; + bulkAction: (action: string, params: TrashBinBulkActionsParams) => Promise; getItems: () => TrashBinItem[]; getRestoredItems: () => TrashBinItem[]; getMeta: () => Meta; diff --git a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepository.ts b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepository.ts index 6f1dd9955fa..7a6c46f449f 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepository.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepository.ts @@ -4,10 +4,11 @@ import { ITrashBinItemMapper, TrashBinItem } from "~/Domain"; import { ITrashBinListGateway, ITrashBinDeleteItemGateway, - ITrashBinRestoreItemGateway + ITrashBinRestoreItemGateway, + ITrashBinBulkActionsGateway } from "~/Gateways"; import { IMetaRepository, Meta } from "@webiny/app-utils"; -import { TrashBinListQueryVariables } from "~/types"; +import { TrashBinBulkActionsParams, TrashBinListQueryVariables } from "~/types"; import { ITrashBinItemsRepository } from "./ITrashBinItemsRepository"; export class TrashBinItemsRepository> @@ -17,6 +18,7 @@ export class TrashBinItemsRepository> private listGateway: ITrashBinListGateway; private deleteGateway: ITrashBinDeleteItemGateway; private restoreGateway: ITrashBinRestoreItemGateway; + private bulkActionsGateway: ITrashBinBulkActionsGateway; private itemMapper: ITrashBinItemMapper; private items: TrashBinItem[] = []; private restoredItems: TrashBinItem[] = []; @@ -27,12 +29,14 @@ export class TrashBinItemsRepository> listGateway: ITrashBinListGateway, deleteGateway: ITrashBinDeleteItemGateway, restoreGateway: ITrashBinRestoreItemGateway, + bulkActionsGateway: ITrashBinBulkActionsGateway, entryMapper: ITrashBinItemMapper ) { this.metaRepository = metaRepository; this.listGateway = listGateway; this.deleteGateway = deleteGateway; this.restoreGateway = restoreGateway; + this.bulkActionsGateway = bulkActionsGateway; this.itemMapper = entryMapper; this.params = {}; makeAutoObservable(this); @@ -120,4 +124,9 @@ export class TrashBinItemsRepository> this.metaRepository.decreaseTotalCount(1); }); } + + async bulkAction(action: string, params: TrashBinBulkActionsParams) { + const { where, search, data } = params; + await this.bulkActionsGateway.execute({ action, where, search, data }); + } } diff --git a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryFactory.ts b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryFactory.ts index 84616840241..7aacf004850 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryFactory.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryFactory.ts @@ -1,6 +1,7 @@ import { IMetaRepository } from "@webiny/app-utils"; import { ITrashBinItemMapper } from "~/Domain"; import { + ITrashBinBulkActionsGateway, ITrashBinDeleteItemGateway, ITrashBinListGateway, ITrashBinRestoreItemGateway @@ -15,6 +16,7 @@ export class TrashBinItemsRepositoryFactory> listGateway: ITrashBinListGateway, deleteGateway: ITrashBinDeleteItemGateway, restoreGateway: ITrashBinRestoreItemGateway, + bulkActionsGateway: ITrashBinBulkActionsGateway, itemMapper: ITrashBinItemMapper ) { const cacheKey = this.getCacheKey(); @@ -27,6 +29,7 @@ export class TrashBinItemsRepositoryFactory> listGateway, deleteGateway, restoreGateway, + bulkActionsGateway, itemMapper ) ); diff --git a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryWithLoading.ts b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryWithLoading.ts index 4975a123699..1943c581f3b 100644 --- a/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryWithLoading.ts +++ b/packages/app-trash-bin/src/Domain/Repositories/TrashBinItems/TrashBinItemsRepositoryWithLoading.ts @@ -1,7 +1,7 @@ import { makeAutoObservable } from "mobx"; import { ILoadingRepository } from "@webiny/app-utils"; import { ITrashBinItemsRepository } from "./ITrashBinItemsRepository"; -import { LoadingActions, TrashBinListQueryVariables } from "~/types"; +import { LoadingActions, TrashBinBulkActionsParams, TrashBinListQueryVariables } from "~/types"; export class TrashBinItemsRepositoryWithLoading implements ITrashBinItemsRepository { private loadingRepository: ILoadingRepository; @@ -59,4 +59,8 @@ export class TrashBinItemsRepositoryWithLoading implements ITrashBinItemsReposit LoadingActions.restore ); } + + async bulkAction(action: string, params: TrashBinBulkActionsParams) { + await this.trashBinItemsRepository.bulkAction(action, params); + } } diff --git a/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/ITrashBinBulkActionsGateway.ts b/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/ITrashBinBulkActionsGateway.ts new file mode 100644 index 00000000000..52336cf4713 --- /dev/null +++ b/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/ITrashBinBulkActionsGateway.ts @@ -0,0 +1,5 @@ +import { TrashBinBulkActionsGatewayParams, TrashBinBulkActionsResponse } from "~/types"; + +export interface ITrashBinBulkActionsGateway { + execute: (params: TrashBinBulkActionsGatewayParams) => Promise; +} diff --git a/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/index.ts b/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/index.ts new file mode 100644 index 00000000000..46254b5e8f6 --- /dev/null +++ b/packages/app-trash-bin/src/Gateways/TrashBinBulkActions/index.ts @@ -0,0 +1 @@ +export * from "./ITrashBinBulkActionsGateway"; diff --git a/packages/app-trash-bin/src/Gateways/index.ts b/packages/app-trash-bin/src/Gateways/index.ts index 8a0ca179f00..63cea357550 100644 --- a/packages/app-trash-bin/src/Gateways/index.ts +++ b/packages/app-trash-bin/src/Gateways/index.ts @@ -1,3 +1,4 @@ +export * from "./TrashBinBulkActions"; export * from "./TrashBinDeleteItem"; export * from "./TrashBinListItems"; export * from "./TrashBinRestoreItem"; diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.test.ts b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.test.ts index 02ce053447c..937d965fb71 100644 --- a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.test.ts +++ b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.test.ts @@ -3,6 +3,7 @@ import { LoadingRepository, MetaRepository, Sorting, SortingRepository } from "@ import { LoadingActions, TrashBinIdentity, TrashBinLocation } from "~/types"; import { TrashBinControllers } from "~/Presentation/TrashBin/TrashBinControllers"; import { + ITrashBinBulkActionsGateway, ITrashBinDeleteItemGateway, ITrashBinListGateway, ITrashBinRestoreItemGateway @@ -56,6 +57,12 @@ const createBinRestoreItemGateway = ({ execute }); +const createBinBulkActionsGateway = ({ + execute +}: ITrashBinBulkActionsGateway): ITrashBinBulkActionsGateway => ({ + execute +}); + class CustomItemMapper implements ITrashBinItemMapper { toDTO(data: Item) { return { @@ -141,8 +148,17 @@ describe("TrashBin", () => { }) }); + const bulkActionGateway = createBinBulkActionsGateway({ + execute: jest.fn().mockImplementation(() => { + return Promise.resolve({ id: "123456789" }); + }) + }); + const itemMapper = new CustomItemMapper(); + const BulkActionDeleteItems = "BulkDeleteItems"; + const BulkActionRestoreItems = "BulkRestoreItems"; + const init = ( listGateway: ITrashBinListGateway, deleteItemGateway: ITrashBinDeleteItemGateway, @@ -160,6 +176,7 @@ describe("TrashBin", () => { listGateway, deleteItemGateway, restoreItemGateway, + bulkActionGateway, itemMapper ); @@ -177,7 +194,9 @@ describe("TrashBin", () => { itemsRepo, selectedRepo, sortRepoWithDefaults, - searchRepo + searchRepo, + BulkActionDeleteItems, + BulkActionRestoreItems ).getControllers() }; }; @@ -784,4 +803,160 @@ describe("TrashBin", () => { retentionPeriod: output }); }); + + it("should be able to perform bulk action - RESTORE", async () => { + const { controllers } = init(listGateway, deleteItemGateway, restoreItemGateway); + + // let's list some entries from the gateway + await controllers.listItems.execute(); + + expect(listGateway.execute).toHaveBeenCalledTimes(1); + + const restoreBulkActionPromise = controllers.restoreBulkAction.execute({ + search: "Custom search", + where: { + title: "Item title" + }, + data: { + any: 1 + } + }); + + await restoreBulkActionPromise; + + expect(bulkActionGateway.execute).toHaveBeenCalledTimes(1); + expect(bulkActionGateway.execute).toHaveBeenCalledWith({ + action: BulkActionRestoreItems, + search: "Custom search", + where: { + title: "Item title" + }, + data: { + any: 1 + } + }); + }); + + it("should be able to perform bulk action - DELETE", async () => { + const { controllers } = init(listGateway, deleteItemGateway, restoreItemGateway); + + // let's list some entries from the gateway + await controllers.listItems.execute(); + + expect(listGateway.execute).toHaveBeenCalledTimes(1); + + const deleteBulkActionPromise = controllers.deleteBulkAction.execute({ + search: "Custom search", + where: { + title: "Item title" + }, + data: { + any: 1 + } + }); + + await deleteBulkActionPromise; + + expect(bulkActionGateway.execute).toHaveBeenCalledTimes(1); + expect(bulkActionGateway.execute).toHaveBeenCalledWith({ + action: BulkActionDeleteItems, + search: "Custom search", + where: { + title: "Item title" + }, + data: { + any: 1 + } + }); + }); + + it("should be able to `selectAll` and `unselectAll` items", async () => { + { + // let's test the functionality by listing items that span multiple pages. + const listGateway = createBinListGateway({ + execute: jest.fn().mockImplementation(() => { + return Promise.resolve([ + [item1, item2, item3], + { totalCount: 4, cursor: "IjMi", hasMoreItems: true } + ]); + }) + }); + + const { presenter, controllers } = init( + listGateway, + deleteItemGateway, + restoreItemGateway + ); + + // let's list some entries from the gateway + await controllers.listItems.execute(); + + expect(listGateway.execute).toHaveBeenCalledTimes(1); + + // let's check the initial vm state + expect(presenter.vm.items.length).toBe(3); + expect(presenter.vm.selectedItems.length).toBe(0); + expect(presenter.vm.allowSelectAll).toBeFalsy(); + expect(presenter.vm.isSelectedAll).toBeFalsy(); + + // let's check the vm state after selecting all items on the page + await controllers.selectItems.execute([item1, item2, item3]); + expect(presenter.vm.selectedItems.length).toBe(3); + expect(presenter.vm.allowSelectAll).toBeTruthy(); + expect(presenter.vm.isSelectedAll).toBeFalsy(); + + // let's check the vm state after selecting all items + await controllers.selectAllItems.execute(); + expect(presenter.vm.selectedItems.length).toBe(3); + expect(presenter.vm.allowSelectAll).toBeTruthy(); + expect(presenter.vm.isSelectedAll).toBeTruthy(); + + // let's check the vm state after unselecting all items + await controllers.unselectAllItems.execute(); + expect(presenter.vm.selectedItems.length).toBe(0); + expect(presenter.vm.allowSelectAll).toBeFalsy(); + expect(presenter.vm.isSelectedAll).toBeFalsy(); + + // let's check the vm state after unselecting one item + await controllers.selectItems.execute([item1, item2, item3]); + expect(presenter.vm.selectedItems.length).toBe(3); + await controllers.selectAllItems.execute(); + expect(presenter.vm.isSelectedAll).toBeTruthy(); + await controllers.selectItems.execute([item1, item2]); + expect(presenter.vm.isSelectedAll).toBeFalsy(); + expect(presenter.vm.allowSelectAll).toBeFalsy(); + } + + { + // let's test the functionality by listing items that span only one page. + const listGateway = createBinListGateway({ + execute: jest.fn().mockImplementation(() => { + return Promise.resolve([ + [item1, item2, item3], + { totalCount: 3, cursor: null, hasMoreItems: false } + ]); + }) + }); + + const { presenter, controllers } = init( + listGateway, + deleteItemGateway, + restoreItemGateway + ); + + // let's list some entries from the gateway + await controllers.listItems.execute(); + + expect(listGateway.execute).toHaveBeenCalledTimes(1); + + // let's check the initial vm state + expect(presenter.vm.items.length).toBe(3); + + // let's check the vm state after selecting all items in the page + await controllers.selectItems.execute([item1, item2, item3]); + expect(presenter.vm.selectedItems.length).toBe(3); + expect(presenter.vm.allowSelectAll).toBeFalsy(); + expect(presenter.vm.isSelectedAll).toBeFalsy(); + } + }); }); diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.tsx b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.tsx index 0912542b5f7..f6296b23514 100644 --- a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.tsx +++ b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBin.tsx @@ -19,6 +19,7 @@ import { TrashBinItemDTO } from "~/Domain"; import { + ITrashBinBulkActionsGateway, ITrashBinDeleteItemGateway, ITrashBinListGateway, ITrashBinRestoreItemGateway @@ -29,6 +30,9 @@ export interface TrashBinProps { listGateway: ITrashBinListGateway; deleteGateway: ITrashBinDeleteItemGateway; restoreGateway: ITrashBinRestoreItemGateway; + bulkActionsGateway: ITrashBinBulkActionsGateway; + deleteBulkActionName: string; + restoreBulkActionName: string; itemMapper: ITrashBinItemMapper; onClose: () => void; onItemAfterRestore: (item: TrashBinItemDTO) => Promise; @@ -66,6 +70,7 @@ export const TrashBin = observer((props: TrashBinProps) => { props.listGateway, props.deleteGateway, props.restoreGateway, + props.bulkActionsGateway, props.itemMapper ); @@ -84,7 +89,9 @@ export const TrashBin = observer((props: TrashBinProps) => { itemsRepository, selectedRepository, sortingRepository, - searchRepository + searchRepository, + props.deleteBulkActionName, + props.restoreBulkActionName ).getControllers(); }, [ itemsRepository, diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinControllers.ts b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinControllers.ts index 15e7aa63e2e..3e0fec624df 100644 --- a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinControllers.ts +++ b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinControllers.ts @@ -1,16 +1,20 @@ import { ISortingRepository } from "@webiny/app-utils"; import { ISearchRepository, ISelectedItemsRepository, ITrashBinItemsRepository } from "~/Domain"; import { + BulkActionsController, DeleteItemController, GetRestoredItemByIdController, ListItemsController, ListMoreItemsController, RestoreItemController, SearchItemsController, + SelectAllItemsController, SelectItemsController, - SortItemsController + SortItemsController, + UnselectAllItemsController } from "~/Presentation/TrashBin/controllers"; import { + BulkActionUseCase, DeleteItemUseCase, GetRestoredItemUseCase, ListItemsUseCase, @@ -19,8 +23,10 @@ import { ListMoreItemsUseCase, RestoreItemUseCase, SearchItemsUseCase, + SelectAllItemsUseCase, SelectItemsUseCase, - SortItemsUseCase + SortItemsUseCase, + UnselectAllItemsUseCase } from "~/UseCases"; export class TrashBinControllers { @@ -28,23 +34,35 @@ export class TrashBinControllers { private readonly selectedRepository: ISelectedItemsRepository; private readonly sortingRepository: ISortingRepository; private readonly searchRepository: ISearchRepository; + private readonly deleteBulkActionName: string; + private readonly restoreBulkActionName: string; constructor( itemsRepository: ITrashBinItemsRepository, selectedRepository: ISelectedItemsRepository, sortingRepository: ISortingRepository, - searchRepository: ISearchRepository + searchRepository: ISearchRepository, + deleteBulkActionName: string, + restoreBulkActionName: string ) { this.itemsRepository = itemsRepository; this.selectedRepository = selectedRepository; this.sortingRepository = sortingRepository; this.searchRepository = searchRepository; + this.deleteBulkActionName = deleteBulkActionName; + this.restoreBulkActionName = restoreBulkActionName; } getControllers() { // Select Items UseCase const selectItemsUseCase = () => new SelectItemsUseCase(this.selectedRepository); + // Select All Items UseCase + const selectAllItemsUseCase = () => new SelectAllItemsUseCase(this.selectedRepository); + + // Unselect All Items UseCase + const unselectAllItemsUseCase = () => new UnselectAllItemsUseCase(this.selectedRepository); + // Sort Items UseCase const sortItemsUseCase = () => new SortItemsUseCase(this.sortingRepository); @@ -73,22 +91,39 @@ export class TrashBinControllers { // Get RestoredItem UseCase const getRestoredItemUseCase = () => new GetRestoredItemUseCase(this.itemsRepository); + // Bulk Action UseCase + const bulkActionUseCase = () => new BulkActionUseCase(this.itemsRepository); + // Create controllers const listItems = new ListItemsController(listItemsUseCase); const listMoreItems = new ListMoreItemsController(listMoreItemsUseCase); const deleteItem = new DeleteItemController(deleteItemUseCase); const restoreItem = new RestoreItemController(restoreItemUseCase); const selectItems = new SelectItemsController(selectItemsUseCase); + const selectAllItems = new SelectAllItemsController(selectAllItemsUseCase); + const unselectAllItems = new UnselectAllItemsController(unselectAllItemsUseCase); const sortItems = new SortItemsController(listItemsUseCase, sortItemsUseCase); const searchItems = new SearchItemsController(listItemsUseCase, searchItemsUseCase); const getRestoredItemById = new GetRestoredItemByIdController(getRestoredItemUseCase); + const restoreBulkAction = new BulkActionsController( + bulkActionUseCase, + this.restoreBulkActionName + ); + const deleteBulkAction = new BulkActionsController( + bulkActionUseCase, + this.deleteBulkActionName + ); return { listItems, listMoreItems, deleteItem, restoreItem, + restoreBulkAction, + deleteBulkAction, selectItems, + selectAllItems, + unselectAllItems, sortItems, searchItems, getRestoredItemById diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinPresenter.ts b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinPresenter.ts index cabfdb8be2f..5ade921715c 100644 --- a/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinPresenter.ts +++ b/packages/app-trash-bin/src/Presentation/TrashBin/TrashBinPresenter.ts @@ -41,6 +41,8 @@ export class TrashBinPresenter { items: this.mapItemsToDTOs(this.itemsRepository.getItems()), restoredItems: this.mapItemsToDTOs(this.itemsRepository.getRestoredItems()), selectedItems: this.mapItemsToDTOs(this.selectedRepository.getSelectedItems()), + allowSelectAll: this.getAllowSelectAll(), + isSelectedAll: this.selectedRepository.getSelectedAllItems(), meta: MetaMapper.toDto(this.itemsRepository.getMeta()), sorting: this.sortingRepository.get().map(sort => SortingMapper.fromDTOtoColumn(sort)), loading: this.itemsRepository.getLoading(), @@ -76,4 +78,13 @@ export class TrashBinPresenter { } return `${this.retentionPeriod} days`; } + + private getAllowSelectAll() { + return ( + this.itemsRepository.getMeta().hasMoreItems && + !!this.itemsRepository.getItems().length && + this.selectedRepository.getSelectedItems().length === + this.itemsRepository.getItems().length + ); + } } diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/BulkActionsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/BulkActionsController.ts new file mode 100644 index 00000000000..9dfa747e025 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/BulkActionsController.ts @@ -0,0 +1,18 @@ +import { IBulkActionUseCase } from "~/UseCases"; +import { IBulkActionsController } from "./IBulkActionsController"; +import { TrashBinBulkActionsParams } from "~/types"; + +export class BulkActionsController implements IBulkActionsController { + private readonly useCaseFactory: () => IBulkActionUseCase; + private readonly action: string; + + constructor(useCaseFactory: () => IBulkActionUseCase, action: string) { + this.useCaseFactory = useCaseFactory; + this.action = action; + } + + async execute(params: TrashBinBulkActionsParams) { + const bulkActionUseCase = this.useCaseFactory(); + await bulkActionUseCase.execute(this.action, params); + } +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/IBulkActionsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/IBulkActionsController.ts new file mode 100644 index 00000000000..0e810acc478 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/IBulkActionsController.ts @@ -0,0 +1,5 @@ +import { TrashBinBulkActionsParams } from "~/types"; + +export interface IBulkActionsController { + execute: (params: TrashBinBulkActionsParams) => Promise; +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/index.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/index.ts new file mode 100644 index 00000000000..0b276d204c4 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/BulkAction/index.ts @@ -0,0 +1,2 @@ +export * from "./IBulkActionsController"; +export * from "./BulkActionsController"; diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/ISelectAllItemsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/ISelectAllItemsController.ts new file mode 100644 index 00000000000..dfee2d5c12f --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/ISelectAllItemsController.ts @@ -0,0 +1,3 @@ +export interface ISelectAllItemsController { + execute: () => Promise; +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/SelectAllItemsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/SelectAllItemsController.ts new file mode 100644 index 00000000000..03fd8b94609 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/SelectAllItemsController.ts @@ -0,0 +1,15 @@ +import { ISelectAllItemsUseCase } from "~/UseCases"; +import { ISelectAllItemsController } from "./ISelectAllItemsController"; + +export class SelectAllItemsController implements ISelectAllItemsController { + private readonly useCaseFactory: () => ISelectAllItemsUseCase; + + constructor(useCaseFactory: () => ISelectAllItemsUseCase) { + this.useCaseFactory = useCaseFactory; + } + + async execute() { + const selectAllItemsUseCase = this.useCaseFactory(); + await selectAllItemsUseCase.execute(); + } +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/index.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/index.ts new file mode 100644 index 00000000000..df906a3e203 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/SelectAllItems/index.ts @@ -0,0 +1,2 @@ +export * from "./ISelectAllItemsController"; +export * from "./SelectAllItemsController"; diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/IUnselectAllItemsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/IUnselectAllItemsController.ts new file mode 100644 index 00000000000..da5b6ea1112 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/IUnselectAllItemsController.ts @@ -0,0 +1,3 @@ +export interface IUnselectAllItemsController { + execute: () => Promise; +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/UnselectAllItemsController.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/UnselectAllItemsController.ts new file mode 100644 index 00000000000..fcb25458272 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/UnselectAllItemsController.ts @@ -0,0 +1,15 @@ +import { IUnselectAllItemsUseCase } from "~/UseCases"; +import { IUnselectAllItemsController } from "./IUnselectAllItemsController"; + +export class UnselectAllItemsController implements IUnselectAllItemsController { + private readonly useCaseFactory: () => IUnselectAllItemsUseCase; + + constructor(useCaseFactory: () => IUnselectAllItemsUseCase) { + this.useCaseFactory = useCaseFactory; + } + + async execute() { + const unselectAllItemsUseCase = this.useCaseFactory(); + await unselectAllItemsUseCase.execute(); + } +} diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/index.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/index.ts new file mode 100644 index 00000000000..eb275aea47a --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/UnselectAllItems/index.ts @@ -0,0 +1,2 @@ +export * from "./IUnselectAllItemsController"; +export * from "./UnselectAllItemsController"; diff --git a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/index.ts b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/index.ts index 4de74c4d6bd..edb835fb0a2 100644 --- a/packages/app-trash-bin/src/Presentation/TrashBin/controllers/index.ts +++ b/packages/app-trash-bin/src/Presentation/TrashBin/controllers/index.ts @@ -1,3 +1,4 @@ +export * from "./BulkAction"; export * from "./DeleteItem"; export * from "./GetRestoredItemById"; export * from "./ListItems"; @@ -5,4 +6,6 @@ export * from "./ListMoreItems"; export * from "./RestoreItem"; export * from "./SearchItems"; export * from "./SelectItems"; +export * from "./SelectAllItems"; export * from "./SortItems"; +export * from "./UnselectAllItems"; diff --git a/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinControllers.ts b/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinControllers.ts index a352b122b0e..7a4588ed350 100644 --- a/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinControllers.ts +++ b/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinControllers.ts @@ -1,12 +1,15 @@ import { + IBulkActionsController, IDeleteItemController, IGetRestoredItemByIdController, IListItemsController, IListMoreItemsController, IRestoreItemController, ISearchItemsController, + ISelectAllItemsController, ISelectItemsController, - ISortItemsController + ISortItemsController, + IUnselectAllItemsController } from "~/Presentation/TrashBin/controllers"; export interface ITrashBinControllers { @@ -16,6 +19,10 @@ export interface ITrashBinControllers { listMoreItems: IListMoreItemsController; listItems: IListItemsController; selectItems: ISelectItemsController; + selectAllItems: ISelectAllItemsController; sortItems: ISortItemsController; searchItems: ISearchItemsController; + unselectAllItems: IUnselectAllItemsController; + restoreBulkAction: IBulkActionsController; + deleteBulkAction: IBulkActionsController; } diff --git a/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinPresenter.ts b/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinPresenter.ts index f9756e721f7..84346711ee1 100644 --- a/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinPresenter.ts +++ b/packages/app-trash-bin/src/Presentation/abstractions/ITrashBinPresenter.ts @@ -6,6 +6,8 @@ export interface TrashBinPresenterViewModel { items: TrashBinItemDTO[]; restoredItems: TrashBinItemDTO[]; selectedItems: TrashBinItemDTO[]; + allowSelectAll: boolean; + isSelectedAll: boolean; sorting: ColumnSorting[]; loading: Record; isEmptyView: boolean; diff --git a/packages/app-trash-bin/src/Presentation/components/BulkActions/BulkActions/BulkActions.tsx b/packages/app-trash-bin/src/Presentation/components/BulkActions/BulkActions/BulkActions.tsx index d5072011808..d16ef7cde0b 100644 --- a/packages/app-trash-bin/src/Presentation/components/BulkActions/BulkActions/BulkActions.tsx +++ b/packages/app-trash-bin/src/Presentation/components/BulkActions/BulkActions/BulkActions.tsx @@ -7,7 +7,11 @@ import { useTrashBinListConfig } from "~/Presentation/configs"; import { useTrashBin } from "~/Presentation/hooks"; import { BulkActionsContainer, BulkActionsInner, ButtonsContainer } from "./BulkActions.styled"; -export const getEntriesLabel = (count = 0): string => { +export const getEntriesLabel = (count: number, isSelectedAll: boolean): string => { + if (isSelectedAll) { + return "all entries"; + } + return `${count} ${count === 1 ? "item" : "items"}`; }; @@ -16,8 +20,12 @@ export const BulkActions = () => { const { vm, selectItems } = useTrashBin(); const headline = useMemo((): string => { - return getEntriesLabel(vm.selectedItems.length) + ` selected:`; - }, [vm.selectedItems]); + if (vm.isSelectedAll) { + return "All entries selected:"; + } + + return getEntriesLabel(vm.selectedItems.length, vm.isSelectedAll) + ` selected:`; + }, [vm.selectedItems, vm.isSelectedAll]); if (!vm.selectedItems.length) { return null; diff --git a/packages/app-trash-bin/src/Presentation/components/BulkActions/DeleteItems/DeleteItems.tsx b/packages/app-trash-bin/src/Presentation/components/BulkActions/DeleteItems/DeleteItems.tsx index f91c6c6da1d..003b1847f5e 100644 --- a/packages/app-trash-bin/src/Presentation/components/BulkActions/DeleteItems/DeleteItems.tsx +++ b/packages/app-trash-bin/src/Presentation/components/BulkActions/DeleteItems/DeleteItems.tsx @@ -1,12 +1,14 @@ import React, { useMemo } from "react"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; import { observer } from "mobx-react-lite"; +import { useSnackbar } from "@webiny/app-admin"; import { TrashBinListConfig } from "~/Presentation/configs"; import { useTrashBin } from "~/Presentation/hooks"; import { getEntriesLabel } from "../BulkActions"; export const BulkActionsDeleteItems = observer(() => { - const { deleteItem } = useTrashBin(); + const { deleteItem, deleteBulkAction } = useTrashBin(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = TrashBinListConfig.Browser.BulkAction; const { IconButton } = useButtons(); @@ -14,8 +16,8 @@ export const BulkActionsDeleteItems = observer(() => { const { showConfirmationDialog, showResultsDialog } = useDialog(); const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + return getEntriesLabel(worker.items.length, worker.isSelectedAll); + }, [worker.items.length, worker.isSelectedAll]); const openDeleteEntriesDialog = () => showConfirmationDialog({ @@ -23,6 +25,19 @@ export const BulkActionsDeleteItems = observer(() => { message: `You are about to permanently delete ${entriesLabel}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk(deleteBulkAction); + worker.resetItems(); + showSnackbar( + "All items will be permanently deleted. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.", + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { await deleteItem(item.id); diff --git a/packages/app-trash-bin/src/Presentation/components/BulkActions/RestoreItems/RestoreItems.tsx b/packages/app-trash-bin/src/Presentation/components/BulkActions/RestoreItems/RestoreItems.tsx index b29d88e3b4d..9f70a6d713c 100644 --- a/packages/app-trash-bin/src/Presentation/components/BulkActions/RestoreItems/RestoreItems.tsx +++ b/packages/app-trash-bin/src/Presentation/components/BulkActions/RestoreItems/RestoreItems.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from "react"; import { ReactComponent as RestoreIcon } from "@material-design-icons/svg/outlined/restore.svg"; import { observer } from "mobx-react-lite"; +import { useSnackbar } from "@webiny/app-admin"; import { TrashBinListConfig } from "~/Presentation/configs"; import { useTrashBin } from "~/Presentation/hooks"; import { getEntriesLabel } from "../BulkActions"; @@ -8,7 +9,9 @@ import { RestoreItemsReportMessage } from "~/Presentation/components/BulkActions import { TrashBinItemDTO } from "~/Domain"; export const BulkActionsRestoreItems = observer(() => { - const { restoreItem, onItemAfterRestore, getRestoredItemById } = useTrashBin(); + const { restoreItem, onItemAfterRestore, getRestoredItemById, restoreBulkAction } = + useTrashBin(); + const { showSnackbar } = useSnackbar(); const { useWorker, useButtons, useDialog } = TrashBinListConfig.Browser.BulkAction; const { IconButton } = useButtons(); @@ -16,8 +19,8 @@ export const BulkActionsRestoreItems = observer(() => { const { showConfirmationDialog, showResultsDialog, hideResultsDialog } = useDialog(); const entriesLabel = useMemo(() => { - return getEntriesLabel(worker.items.length); - }, [worker.items.length]); + return getEntriesLabel(worker.items.length, worker.isSelectedAll); + }, [worker.items.length, worker.isSelectedAll]); const onLocationClick = useCallback( async (item: TrashBinItemDTO) => { @@ -33,6 +36,19 @@ export const BulkActionsRestoreItems = observer(() => { message: `You are about to restore ${entriesLabel}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { + if (worker.isSelectedAll) { + await worker.processInBulk(restoreBulkAction); + worker.resetItems(); + showSnackbar( + "All items will be restored. This process will be carried out in the background and may take some time. You can safely navigate away from this page while the process is running.", + { + dismissIcon: true, + timeout: -1 + } + ); + return; + } + await worker.processInSeries(async ({ item, report }) => { try { await restoreItem(item.id); diff --git a/packages/app-trash-bin/src/Presentation/components/SelectAll/Messages.tsx b/packages/app-trash-bin/src/Presentation/components/SelectAll/Messages.tsx new file mode 100644 index 00000000000..429c372789a --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/components/SelectAll/Messages.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Typography } from "@webiny/ui/Typography"; +import { getEntriesLabel } from "./SelectAll"; +import { Button, MessageContainer } from "./SelectAll.styled"; + +export interface MessagesProps { + onClick: () => void; + selected: number; +} + +export const SelectAllMessage = (props: MessagesProps) => { + return ( + + {`${getEntriesLabel(props.selected)} selected.`} + + + ); +}; + +export const ClearSelectionMessage = (props: MessagesProps) => { + return ( + + {`All items are selected.`} + + + ); +}; diff --git a/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.styled.tsx b/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.styled.tsx new file mode 100644 index 00000000000..bb1889b79e5 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.styled.tsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; +import { ButtonDefault } from "@webiny/ui/Button"; + +export const SelectAllContainer = styled.div` + width: 100%; + height: auto; + background-color: var(--mdc-theme-background); + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + padding: 16px; + text-align: center; +`; + +export const MessageContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +export const Button = styled(ButtonDefault)` + margin-left: 8px; +`; diff --git a/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.tsx b/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.tsx new file mode 100644 index 00000000000..60e86234f15 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/components/SelectAll/SelectAll.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useTrashBin } from "~/Presentation/hooks"; +import { ClearSelectionMessage, SelectAllMessage } from "./Messages"; +import { SelectAllContainer } from "./SelectAll.styled"; + +export const getEntriesLabel = (count = 0): string => { + return `${count} ${count === 1 ? "item" : "items"}`; +}; + +export const SelectAll = () => { + const { vm, selectAllItems, unselectAllItems } = useTrashBin(); + + if (!vm.allowSelectAll) { + return null; + } + + return ( + + {vm.isSelectedAll ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/app-trash-bin/src/Presentation/components/SelectAll/index.ts b/packages/app-trash-bin/src/Presentation/components/SelectAll/index.ts new file mode 100644 index 00000000000..72c9bbafe39 --- /dev/null +++ b/packages/app-trash-bin/src/Presentation/components/SelectAll/index.ts @@ -0,0 +1 @@ +export * from "./SelectAll"; diff --git a/packages/app-trash-bin/src/Presentation/components/TrashBinOverlay/TrashBinOverlay.tsx b/packages/app-trash-bin/src/Presentation/components/TrashBinOverlay/TrashBinOverlay.tsx index 87d4ca084c4..272ec3555ac 100644 --- a/packages/app-trash-bin/src/Presentation/components/TrashBinOverlay/TrashBinOverlay.tsx +++ b/packages/app-trash-bin/src/Presentation/components/TrashBinOverlay/TrashBinOverlay.tsx @@ -7,6 +7,7 @@ import { SearchInput } from "~/Presentation/components/SearchInput"; import { BulkActions } from "~/Presentation/components/BulkActions"; import { Empty } from "~/Presentation/components/Empty"; import { Table } from "~/Presentation/components/Table"; +import { SelectAll } from "~/Presentation/components/SelectAll"; import { BottomInfoBar } from "~/Presentation/components/BottomInfoBar"; import { useTrashBin } from "~/Presentation/hooks"; @@ -31,6 +32,7 @@ export const TrashBinOverlay = (props: TrashBinOverlayProps) => { barMiddle={} > + onTableScroll({ scrollFrame })}> {vm.isEmptyView ? : } diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/BulkAction.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/BulkAction.tsx index 83deefb896a..96e644af27c 100644 --- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/BulkAction.tsx +++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/BulkAction.tsx @@ -3,6 +3,7 @@ import { CallbackParams, useButtons, useDialogWithReport, Worker } from "@webiny import { Property, useIdGenerator } from "@webiny/react-properties"; import { useTrashBin } from "~/Presentation/hooks"; import { TrashBinItemDTO } from "~/Domain"; +import { TrashBinBulkActionsParams } from "~/types"; export interface BulkActionConfig { name: string; @@ -73,8 +74,15 @@ const useWorker = () => { }: CallbackParams) => Promise, chunkSize?: number ) => worker.processInSeries(callback, chunkSize), + processInBulk: async ( + callback: (params: TrashBinBulkActionsParams) => Promise, + data?: Record + ) => { + await callback({ search: vm.searchQuery, data }); + }, resetItems: resetItems, - results: worker.results + results: worker.results, + isSelectedAll: vm.isSelectedAll }; }; diff --git a/packages/app-trash-bin/src/Presentation/hooks/useTrashBin.tsx b/packages/app-trash-bin/src/Presentation/hooks/useTrashBin.tsx index e60e5258cde..5922f315698 100644 --- a/packages/app-trash-bin/src/Presentation/hooks/useTrashBin.tsx +++ b/packages/app-trash-bin/src/Presentation/hooks/useTrashBin.tsx @@ -3,6 +3,7 @@ import { autorun } from "mobx"; import { createGenericContext } from "@webiny/app-admin"; import { ITrashBinControllers, ITrashBinPresenter } from "~/Presentation/abstractions"; import { TrashBinItemDTO } from "~/Domain"; +import { TrashBinBulkActionsParams } from "~/types"; export interface TrashBinContext { controllers: ITrashBinControllers; @@ -38,6 +39,17 @@ export const useTrashBin = () => { [context.controllers.restoreItem] ); + const restoreBulkAction = useCallback( + (params: TrashBinBulkActionsParams) => + context.controllers.restoreBulkAction.execute(params), + [context.controllers.restoreBulkAction] + ); + + const deleteBulkAction = useCallback( + (params: TrashBinBulkActionsParams) => context.controllers.deleteBulkAction.execute(params), + [context.controllers.deleteBulkAction] + ); + const listItems = useCallback( () => context.controllers.listItems.execute(), [context.controllers.listItems] @@ -58,6 +70,16 @@ export const useTrashBin = () => { [context.controllers.selectItems] ); + const selectAllItems = useCallback( + () => context.controllers.selectAllItems.execute(), + [context.controllers.selectAllItems] + ); + + const unselectAllItems = useCallback( + () => context.controllers.unselectAllItems.execute(), + [context.controllers.unselectAllItems] + ); + const sortItems = useMemo( () => context.controllers.sortItems.execute, [context.controllers.sortItems] @@ -77,8 +99,12 @@ export const useTrashBin = () => { listMoreItems, searchItems, selectItems, + selectAllItems, + unselectAllItems, sortItems, - getRestoredItemById + getRestoredItemById, + restoreBulkAction, + deleteBulkAction }; }; diff --git a/packages/app-trash-bin/src/Presentation/index.tsx b/packages/app-trash-bin/src/Presentation/index.tsx index fd132841cc7..9662aefb5db 100644 --- a/packages/app-trash-bin/src/Presentation/index.tsx +++ b/packages/app-trash-bin/src/Presentation/index.tsx @@ -3,7 +3,8 @@ import { AcoWithConfig } from "@webiny/app-aco"; import { ITrashBinDeleteItemGateway, ITrashBinListGateway, - ITrashBinRestoreItemGateway + ITrashBinRestoreItemGateway, + ITrashBinBulkActionsGateway } from "~/Gateways"; import { ITrashBinItemMapper, TrashBinItemDTO } from "~/Domain"; import { TrashBinRenderer } from "~/Presentation/TrashBinRenderer"; @@ -22,10 +23,13 @@ interface TrashBinRenderProps { export type TrashBinProps = { render: TrashBinRenderProps; + bulkActionsGateway: ITrashBinBulkActionsGateway; listGateway: ITrashBinListGateway; deleteGateway: ITrashBinDeleteItemGateway; restoreGateway: ITrashBinRestoreItemGateway; itemMapper: ITrashBinItemMapper; + deleteBulkActionName: string; + restoreBulkActionName: string; onClose?: () => void; onItemAfterRestore?: (item: TrashBinItemDTO) => Promise; show?: boolean; diff --git a/packages/app-trash-bin/src/UseCases/BulkAction/BulkActionUseCase.ts b/packages/app-trash-bin/src/UseCases/BulkAction/BulkActionUseCase.ts new file mode 100644 index 00000000000..a0d38a86af0 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/BulkAction/BulkActionUseCase.ts @@ -0,0 +1,17 @@ +import { makeAutoObservable } from "mobx"; +import { ITrashBinItemsRepository } from "~/Domain/Repositories"; +import { IBulkActionUseCase } from "./IBulkActionUseCase"; +import { TrashBinBulkActionsParams } from "~/types"; + +export class BulkActionUseCase implements IBulkActionUseCase { + private repository: ITrashBinItemsRepository; + + constructor(repository: ITrashBinItemsRepository) { + this.repository = repository; + makeAutoObservable(this); + } + + async execute(action: string, params: TrashBinBulkActionsParams) { + await this.repository.bulkAction(action, params); + } +} diff --git a/packages/app-trash-bin/src/UseCases/BulkAction/IBulkActionUseCase.ts b/packages/app-trash-bin/src/UseCases/BulkAction/IBulkActionUseCase.ts new file mode 100644 index 00000000000..b7fde69ec84 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/BulkAction/IBulkActionUseCase.ts @@ -0,0 +1,5 @@ +import { TrashBinBulkActionsParams } from "~/types"; + +export interface IBulkActionUseCase { + execute: (action: string, params: TrashBinBulkActionsParams) => Promise; +} diff --git a/packages/app-trash-bin/src/UseCases/BulkAction/index.ts b/packages/app-trash-bin/src/UseCases/BulkAction/index.ts new file mode 100644 index 00000000000..273ebe7bf52 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/BulkAction/index.ts @@ -0,0 +1,2 @@ +export * from "./IBulkActionUseCase"; +export * from "./BulkActionUseCase"; diff --git a/packages/app-trash-bin/src/UseCases/SelectAllItems/ISelectAllItemsUseCase.ts b/packages/app-trash-bin/src/UseCases/SelectAllItems/ISelectAllItemsUseCase.ts new file mode 100644 index 00000000000..9208f8a1c40 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/SelectAllItems/ISelectAllItemsUseCase.ts @@ -0,0 +1,3 @@ +export interface ISelectAllItemsUseCase { + execute: () => Promise; +} diff --git a/packages/app-trash-bin/src/UseCases/SelectAllItems/SelectAllItemsUseCase.ts b/packages/app-trash-bin/src/UseCases/SelectAllItems/SelectAllItemsUseCase.ts new file mode 100644 index 00000000000..6c30a0d32f0 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/SelectAllItems/SelectAllItemsUseCase.ts @@ -0,0 +1,16 @@ +import { makeAutoObservable } from "mobx"; +import { ISelectedItemsRepository } from "~/Domain"; +import { ISelectAllItemsUseCase } from "./ISelectAllItemsUseCase"; + +export class SelectAllItemsUseCase implements ISelectAllItemsUseCase { + private repository: ISelectedItemsRepository; + + constructor(repository: ISelectedItemsRepository) { + this.repository = repository; + makeAutoObservable(this); + } + + async execute() { + await this.repository.selectAllItems(); + } +} diff --git a/packages/app-trash-bin/src/UseCases/SelectAllItems/index.ts b/packages/app-trash-bin/src/UseCases/SelectAllItems/index.ts new file mode 100644 index 00000000000..fd56914f4a5 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/SelectAllItems/index.ts @@ -0,0 +1,2 @@ +export * from "./ISelectAllItemsUseCase"; +export * from "./SelectAllItemsUseCase"; diff --git a/packages/app-trash-bin/src/UseCases/UnSelectAllItems/IUnselectAllItemsUseCase.ts b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/IUnselectAllItemsUseCase.ts new file mode 100644 index 00000000000..8e10bc49632 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/IUnselectAllItemsUseCase.ts @@ -0,0 +1,3 @@ +export interface IUnselectAllItemsUseCase { + execute: () => Promise; +} diff --git a/packages/app-trash-bin/src/UseCases/UnSelectAllItems/UnselectAllItemsUseCase.ts b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/UnselectAllItemsUseCase.ts new file mode 100644 index 00000000000..f0283d97dea --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/UnselectAllItemsUseCase.ts @@ -0,0 +1,16 @@ +import { makeAutoObservable } from "mobx"; +import { ISelectedItemsRepository } from "~/Domain"; +import { IUnselectAllItemsUseCase } from "./IUnselectAllItemsUseCase"; + +export class UnselectAllItemsUseCase implements IUnselectAllItemsUseCase { + private repository: ISelectedItemsRepository; + + constructor(repository: ISelectedItemsRepository) { + this.repository = repository; + makeAutoObservable(this); + } + + async execute() { + await this.repository.unselectAllItems(); + } +} diff --git a/packages/app-trash-bin/src/UseCases/UnSelectAllItems/index.ts b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/index.ts new file mode 100644 index 00000000000..51a83c4cea9 --- /dev/null +++ b/packages/app-trash-bin/src/UseCases/UnSelectAllItems/index.ts @@ -0,0 +1,2 @@ +export * from "./IUnselectAllItemsUseCase"; +export * from "./UnselectAllItemsUseCase"; diff --git a/packages/app-trash-bin/src/UseCases/index.ts b/packages/app-trash-bin/src/UseCases/index.ts index b157ae850f0..6b74ce6b9d4 100644 --- a/packages/app-trash-bin/src/UseCases/index.ts +++ b/packages/app-trash-bin/src/UseCases/index.ts @@ -1,3 +1,4 @@ +export * from "./BulkAction"; export * from "./DeleteItem"; export * from "./GetRestoredItem"; export * from "./ListItems"; @@ -6,3 +7,5 @@ export * from "./RestoreItem"; export * from "./SearchItems"; export * from "./SortItems"; export * from "./SelectItems"; +export * from "./SelectAllItems"; +export * from "./UnSelectAllItems"; diff --git a/packages/app-trash-bin/src/types.ts b/packages/app-trash-bin/src/types.ts index 2fceb5eaf7d..850598d9e7e 100644 --- a/packages/app-trash-bin/src/types.ts +++ b/packages/app-trash-bin/src/types.ts @@ -30,3 +30,21 @@ export enum LoadingActions { delete = "DELETE", restore = "RESTORE" } + +export interface TrashBinBulkActionsParams { + where?: { + [key: string]: any; + }; + search?: string; + data?: { + [key: string]: any; + }; +} + +export interface TrashBinBulkActionsGatewayParams extends TrashBinBulkActionsParams { + action: string; +} + +export interface TrashBinBulkActionsResponse { + id: string; +} 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 2618162f417..b14427c4aa6 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 @@ -22,7 +22,7 @@ "@webiny/api-headless-cms": "latest", "@webiny/api-headless-cms-aco": "latest", "@webiny/api-headless-cms-ddb-es": "latest", - "@webiny/api-headless-cms-tasks": "latest", + "@webiny/api-headless-cms-bulk-actions": "latest", "@webiny/api-record-locking": "latest", "@webiny/api-page-builder": "latest", "@webiny/api-page-builder-aco": "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 2056a49a986..c2c7a271db6 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 @@ -28,7 +28,7 @@ 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"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb-es"; -import { createHcmsTasks } from "@webiny/api-headless-cms-tasks"; +import { createHcmsBulkActions } from "@webiny/api-headless-cms-bulk-actions"; import { createAco } from "@webiny/api-aco"; import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import { createAcoHcmsContext } from "@webiny/api-headless-cms-aco"; @@ -117,7 +117,7 @@ export const handler = createHandler({ createAco(), createAcoPageBuilderContext(), createAcoHcmsContext(), - createHcmsTasks(), + createHcmsBulkActions(), createAuditLogs(), scaffoldsPlugins(), extensions() diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts index 535a088f930..6407339ec4f 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts @@ -12,7 +12,7 @@ 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 { HcmsAcoContext } from "@webiny/api-headless-cms-aco/types"; -import { HcmsTasksContext } from "@webiny/api-headless-cms-tasks/types"; +import { HcmsBulkActionsContext } from "@webiny/api-headless-cms-bulk-actions/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 @@ -34,5 +34,5 @@ export interface Context AcoContext, PbAcoContext, HcmsAcoContext, - HcmsTasksContext, + HcmsBulkActionsContext, CmsContext {} 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 4981a0191f7..8c966dd7ded 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 @@ -22,7 +22,7 @@ "@webiny/api-headless-cms": "latest", "@webiny/api-headless-cms-aco": "latest", "@webiny/api-headless-cms-ddb-es": "latest", - "@webiny/api-headless-cms-tasks": "latest", + "@webiny/api-headless-cms-bulk-actions": "latest", "@webiny/api-record-locking": "latest", "@webiny/api-page-builder": "latest", "@webiny/api-page-builder-aco": "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 d1199759a1e..6bd25838ad4 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 @@ -28,7 +28,7 @@ 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"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb-es"; -import { createHcmsTasks } from "@webiny/api-headless-cms-tasks"; +import { createHcmsBulkActions } from "@webiny/api-headless-cms-bulk-actions"; import { createAco } from "@webiny/api-aco"; import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import { createAcoHcmsContext } from "@webiny/api-headless-cms-aco"; @@ -117,7 +117,7 @@ export const handler = createHandler({ createAco(), createAcoPageBuilderContext(), createAcoHcmsContext(), - createHcmsTasks(), + createHcmsBulkActions(), createAuditLogs(), scaffoldsPlugins(), extensions() diff --git a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/types.ts b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/types.ts index 535a088f930..6407339ec4f 100644 --- a/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/types.ts +++ b/packages/cwp-template-aws/template/ddb-os/apps/api/graphql/src/types.ts @@ -12,7 +12,7 @@ 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 { HcmsAcoContext } from "@webiny/api-headless-cms-aco/types"; -import { HcmsTasksContext } from "@webiny/api-headless-cms-tasks/types"; +import { HcmsBulkActionsContext } from "@webiny/api-headless-cms-bulk-actions/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 @@ -34,5 +34,5 @@ export interface Context AcoContext, PbAcoContext, HcmsAcoContext, - HcmsTasksContext, + HcmsBulkActionsContext, CmsContext {} 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 c906b6ef7e7..b6183e9c83b 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 @@ -22,7 +22,7 @@ "@webiny/api-headless-cms": "latest", "@webiny/api-headless-cms-aco": "latest", "@webiny/api-headless-cms-ddb": "latest", - "@webiny/api-headless-cms-tasks": "latest", + "@webiny/api-headless-cms-bulk-actions": "latest", "@webiny/api-record-locking": "latest", "@webiny/api-page-builder": "latest", "@webiny/api-page-builder-aco": "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 ac96cd22ddd..ccc3f5ac54d 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 @@ -24,7 +24,7 @@ import { createFormBuilder } from "@webiny/api-form-builder"; import { createFormBuilderStorageOperations } from "@webiny/api-form-builder-so-ddb"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; -import { createHcmsTasks } from "@webiny/api-headless-cms-tasks"; +import { createHcmsBulkActions } from "@webiny/api-headless-cms-bulk-actions"; import { createAco } from "@webiny/api-aco"; import { createAcoPageBuilderContext } from "@webiny/api-page-builder-aco"; import { createAcoHcmsContext } from "@webiny/api-headless-cms-aco"; @@ -103,7 +103,7 @@ export const handler = createHandler({ createAcoPageBuilderContext(), createAuditLogs(), createAcoHcmsContext(), - createHcmsTasks(), + createHcmsBulkActions(), scaffoldsPlugins(), extensions() ], diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts index f709473687e..35906918898 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts @@ -11,7 +11,7 @@ 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 { HcmsAcoContext } from "@webiny/api-headless-cms-aco/types"; -import { HcmsTasksContext } from "@webiny/api-headless-cms-tasks/types"; +import { HcmsBulkActionsContext } from "@webiny/api-headless-cms-bulk-actions/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 @@ -32,5 +32,5 @@ export interface Context AcoContext, PbAcoContext, HcmsAcoContext, - HcmsTasksContext, + HcmsBulkActionsContext, CmsContext {} diff --git a/scripts/listPackagesWithTests.js b/scripts/listPackagesWithTests.js index 544dc6c49e6..c1681147011 100644 --- a/scripts/listPackagesWithTests.js +++ b/scripts/listPackagesWithTests.js @@ -99,10 +99,10 @@ const CUSTOM_HANDLERS = { "api-headless-cms-ddb-es": () => { return ["packages/api-headless-cms-ddb-es --storage=ddb-es,ddb"]; }, - "api-headless-cms-tasks": () => { + "api-headless-cms-bulk-actions": () => { return [ - "packages/api-headless-cms-tasks --storage=ddb", - "packages/api-headless-cms-tasks --storage=ddb-es,ddb" + "packages/api-headless-cms-bulk-actions --storage=ddb", + "packages/api-headless-cms-bulk-actions --storage=ddb-es,ddb" ]; }, "api-apw": () => { diff --git a/yarn.lock b/yarn.lock index 5194413a1c3..2e28b0c4ec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15214,6 +15214,37 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-headless-cms-bulk-actions@0.0.0, @webiny/api-headless-cms-bulk-actions@workspace:packages/api-headless-cms-bulk-actions": + version: 0.0.0-use.local + resolution: "@webiny/api-headless-cms-bulk-actions@workspace:packages/api-headless-cms-bulk-actions" + dependencies: + "@babel/cli": ^7.23.9 + "@babel/core": ^7.24.0 + "@babel/preset-env": ^7.24.0 + "@babel/preset-typescript": ^7.23.3 + "@babel/runtime": ^7.24.0 + "@webiny/api": 0.0.0 + "@webiny/api-admin-users": 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-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/wcp": 0.0.0 + graphql: ^15.8.0 + ttypescript: ^1.5.13 + typescript: ^4.7.4 + languageName: unknown + linkType: soft + "@webiny/api-headless-cms-ddb-es@0.0.0, @webiny/api-headless-cms-ddb-es@workspace:packages/api-headless-cms-ddb-es": version: 0.0.0-use.local resolution: "@webiny/api-headless-cms-ddb-es@workspace:packages/api-headless-cms-ddb-es" @@ -15316,36 +15347,6 @@ __metadata: languageName: unknown linkType: soft -"@webiny/api-headless-cms-tasks@0.0.0, @webiny/api-headless-cms-tasks@workspace:packages/api-headless-cms-tasks": - version: 0.0.0-use.local - resolution: "@webiny/api-headless-cms-tasks@workspace:packages/api-headless-cms-tasks" - dependencies: - "@babel/cli": ^7.23.9 - "@babel/core": ^7.24.0 - "@babel/preset-env": ^7.24.0 - "@babel/preset-typescript": ^7.23.3 - "@babel/runtime": ^7.24.0 - "@webiny/api": 0.0.0 - "@webiny/api-admin-users": 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-graphql": 0.0.0 - "@webiny/plugins": 0.0.0 - "@webiny/project-utils": 0.0.0 - "@webiny/tasks": 0.0.0 - "@webiny/wcp": 0.0.0 - graphql: ^15.8.0 - ttypescript: ^1.5.13 - typescript: ^4.7.4 - languageName: unknown - linkType: soft - "@webiny/api-headless-cms@0.0.0, @webiny/api-headless-cms@workspace:packages/api-headless-cms": version: 0.0.0-use.local resolution: "@webiny/api-headless-cms@workspace:packages/api-headless-cms" @@ -19994,8 +19995,8 @@ __metadata: "@webiny/api-form-builder-so-ddb": 0.0.0 "@webiny/api-headless-cms": 0.0.0 "@webiny/api-headless-cms-aco": 0.0.0 + "@webiny/api-headless-cms-bulk-actions": 0.0.0 "@webiny/api-headless-cms-ddb": 0.0.0 - "@webiny/api-headless-cms-tasks": 0.0.0 "@webiny/api-i18n": 0.0.0 "@webiny/api-i18n-content": 0.0.0 "@webiny/api-i18n-ddb": 0.0.0