diff --git a/.github/actions/check-pr-status/jest.config.js b/.github/actions/check-pr-status/jest.config.js new file mode 100644 index 0000000000..35fb10228e --- /dev/null +++ b/.github/actions/check-pr-status/jest.config.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = { + preset: '../../../jest-preset.unit.js', + displayName: 'Github action check-pr-status', +}; diff --git a/.github/actions/check-pr-status/package.json b/.github/actions/check-pr-status/package.json index f83426e776..dbbac76254 100644 --- a/.github/actions/check-pr-status/package.json +++ b/.github/actions/check-pr-status/package.json @@ -1,6 +1,6 @@ { "name": "check-pr-status", - "version": "4.9.2", + "version": "4.10.1", "main": "dist/index.js", "license": "MIT", "private": true, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9f5988f1bc..82cb946a0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,9 @@ updates: - package-ecosystem: npm directory: / schedule: - interval: daily + interval: weekly + day: sunday + time: '22:00' versioning-strategy: increase ignore: # Only allow patch as minor babel versions need to be upgraded all together @@ -14,6 +16,7 @@ updates: - dependency-name: '*' update-types: + - 'version-update:semver-patch' - 'version-update:semver-major' labels: @@ -22,7 +25,9 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: daily + interval: weekly + day: sunday + time: '22:00' labels: - 'source: dependencies' - 'pr: chore' diff --git a/.github/filters.yaml b/.github/filters.yaml new file mode 100644 index 0000000000..bfcd89e9ea --- /dev/null +++ b/.github/filters.yaml @@ -0,0 +1,14 @@ +backend: + - 'packages/**/package.json' + - 'packages/**/server/**/*.(js|ts)' + - 'packages/**/strapi-server.js' + - 'packages/{utils,generators,cli,providers}/**' + - 'packages/core/*/{lib,bin,ee}/**' + - 'api-tests/**' +frontend: + - 'packages/**/package.json' + - 'packages/**/admin/src/**' + - 'packages/**/admin/ee/admin/**' + - 'packages/**/strapi-admin.js' + - 'packages/core/helper-plugin/**' + - 'packages/admin-test-utils/**' diff --git a/.github/jest.config.js b/.github/jest.config.js deleted file mode 100644 index 39009ed0a8..0000000000 --- a/.github/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - displayName: '.github', -}; diff --git a/.github/workflows/skipped_tests.yml b/.github/workflows/skipped_tests.yml index ba007631bc..f6e81dd284 100644 --- a/.github/workflows/skipped_tests.yml +++ b/.github/workflows/skipped_tests.yml @@ -11,12 +11,28 @@ concurrency: cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + nonDoc: ${{ steps.filter.outputs.nonDoc }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + # For pull requests it's not necessary to checkout the code + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: .github/filters.yaml + lint: name: 'lint (node: ${{ matrix.node }})' runs-on: ubuntu-latest strategy: matrix: - node: [14, 16, 18] + node: [18] steps: - run: echo "Skipped" @@ -34,6 +50,16 @@ jobs: name: 'unit_front (node: ${{ matrix.node }})' needs: [lint] runs-on: ubuntu-latest + strategy: + matrix: + node: [18] + steps: + - run: echo "Skipped" + + build: + name: 'build (node: ${{ matrix.node }})' + needs: [changes, lint, unit_front] + runs-on: ubuntu-latest strategy: matrix: node: [14, 16, 18] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ff42dc63c1..df0c237ec8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,8 +18,25 @@ permissions: actions: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: .github/filters.yaml + lint: name: 'lint (node: ${{ matrix.node }})' + needs: [changes] runs-on: ubuntu-latest strategy: matrix: @@ -44,7 +61,7 @@ jobs: unit_back: name: 'unit_back (node: ${{ matrix.node }})' - needs: [lint] + needs: [changes, lint] runs-on: ubuntu-latest strategy: matrix: @@ -69,7 +86,7 @@ jobs: unit_front: name: 'unit_front (node: ${{ matrix.node }})' - needs: [lint] + needs: [changes, lint] runs-on: ubuntu-latest strategy: matrix: @@ -87,12 +104,14 @@ jobs: key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }} - uses: nrwl/nx-set-shas@v3 - run: yarn install --immutable + - name: Run build:ts for admin-test-utils + run: yarn build --projects=@strapi/admin-test-utils,@strapi/helper-plugin --skip-nx-cache - name: Run test run: yarn nx affected --target=test:front --nx-ignore-cycles build: name: 'build (node: ${{ matrix.node }})' - needs: [lint, unit_front] + needs: [changes, lint, unit_front] runs-on: ubuntu-latest strategy: matrix: @@ -111,8 +130,9 @@ jobs: run: yarn build --projects=@strapi/admin,@strapi/helper-plugin api_ce_pg: + if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[CE] API Integration (postgres, node: ${{ matrix.node }})' strategy: matrix: @@ -151,8 +171,9 @@ jobs: dbOptions: '--dbclient=postgres --dbhost=localhost --dbport=5432 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi' api_ce_mysql: + if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[CE] API Integration (mysql:latest, client: ${{ matrix.db_client }}, node: ${{ matrix.node }})' strategy: matrix: @@ -190,8 +211,9 @@ jobs: dbOptions: '--dbclient=${{ matrix.db_client }} --dbhost=localhost --dbport=3306 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi' api_ce_mysql_5: + if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[CE] API Integration (mysql:5, client: ${{ matrix.db_client }} , node: ${{ matrix.node }})' strategy: matrix: @@ -228,8 +250,9 @@ jobs: dbOptions: '--dbclient=${{ matrix.db_client }} --dbhost=localhost --dbport=3306 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi' api_ce_sqlite: + if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[CE] API Integration (sqlite, client: ${{ matrix.sqlite_pkg }}, node: ${{ matrix.node }})' strategy: matrix: @@ -254,9 +277,9 @@ jobs: # EE api_ee_pg: runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[EE] API Integration (postgres, node: ${{ matrix.node }})' - if: github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') + if: needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') env: STRAPI_LICENSE: ${{ secrets.strapiLicense }} strategy: @@ -298,9 +321,9 @@ jobs: api_ee_mysql: runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[EE] API Integration (mysql:latest, client: ${{ matrix.db_client }}, node: ${{ matrix.node }})' - if: github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') + if: needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') env: STRAPI_LICENSE: ${{ secrets.strapiLicense }} strategy: @@ -341,9 +364,9 @@ jobs: api_ee_sqlite: runs-on: ubuntu-latest - needs: [lint, unit_back, unit_front] + needs: [changes, lint, unit_back, unit_front] name: '[EE] API Integration (sqlite, client: ${{ matrix.sqlite_pkg }}, node: ${{ matrix.node }})' - if: github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') + if: needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') env: STRAPI_LICENSE: ${{ secrets.strapiLicense }} strategy: diff --git a/.husky/pre-commit b/.husky/pre-commit index 70b64877d2..5a182ef106 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,3 @@ . "$(dirname -- "$0")/_/husky.sh" yarn lint-staged -yarn nx affected:lint --uncommitted --nx-ignore-cycles diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 769a89eba1..5381a13472 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,7 @@ The Strapi core team will review your pull request and either merge it, request **Before submitting your pull request** make sure the following requirements are fulfilled: - Fork the repository and create your new branch from `main`. +- Run `yarn install` in the root of the repository. - Run `yarn setup` in the root of the repository. - If you've fixed a bug or added code that should be tested, please make sure to add tests - Ensure the following test suites are passing: @@ -78,6 +79,7 @@ Go to the root of the repository and run the setup: ```bash cd strapi +yarn install yarn setup ``` @@ -183,4 +185,4 @@ Before submitting an issue you need to make sure: - Make sure your application has a clean `node_modules` directory, meaning: - you didn't link any dependencies (e.g., by running `yarn link`) - you haven't made any inline changes to files in the `node_modules` directory - - you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn setup`. + - you don't have any global dependency loops. If you aren't sure, the easiest way to double-check any of the above is to run: `$ rm -rf node_modules && yarn cache clean && yarn install && yarn setup`. diff --git a/api-tests/core/admin/admin-permission.test.api.js b/api-tests/core/admin/admin-permission.test.api.js index 6e08d6c257..e1df822ade 100644 --- a/api-tests/core/admin/admin-permission.test.api.js +++ b/api-tests/core/admin/admin-permission.test.api.js @@ -373,6 +373,12 @@ describe('Role CRUD End to End', () => { "displayName": "Update", "subCategory": "options", }, + { + "action": "admin::review-workflows.read", + "category": "review workflows", + "displayName": "Read", + "subCategory": "options", + }, { "action": "admin::roles.create", "category": "users and roles", diff --git a/api-tests/core/admin/ee/provider-login.test.api.js b/api-tests/core/admin/ee/provider-login.test.api.js index b722cfe3dc..f4c120cc01 100644 --- a/api-tests/core/admin/ee/provider-login.test.api.js +++ b/api-tests/core/admin/ee/provider-login.test.api.js @@ -2,17 +2,10 @@ const { createStrapiInstance } = require('api-tests/strapi'); const { createAuthRequest, createRequest } = require('api-tests/request'); -const { createUtils } = require('api-tests/utils'); +const { createUtils, describeOnCondition } = require('api-tests/utils'); const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; -if (edition === 'CE') { - test('Provider Login (skipped)', () => { - expect(true).toBeTruthy(); - }); - return; -} - let strapi; let utils; const requests = { @@ -54,7 +47,7 @@ const deleteFixtures = async () => { await utils.deleteRolesById([localData.restrictedRole.id]); }; -describe('Provider Login', () => { +describeOnCondition(edition === 'EE')('Provider Login', () => { let hasSSO; beforeAll(async () => { diff --git a/api-tests/core/admin/ee/review-workflows.test.api.js b/api-tests/core/admin/ee/review-workflows.test.api.js new file mode 100644 index 0000000000..4246c9aec9 --- /dev/null +++ b/api-tests/core/admin/ee/review-workflows.test.api.js @@ -0,0 +1,527 @@ +'use strict'; + +const { mapAsync } = require('@strapi/utils'); + +const { createStrapiInstance } = require('api-tests/strapi'); +const { createAuthRequest, createRequest } = require('api-tests/request'); +const { createTestBuilder } = require('api-tests/builder'); +const { describeOnCondition } = require('api-tests/utils'); + +const { + STAGE_MODEL_UID, + WORKFLOW_MODEL_UID, + ENTITY_STAGE_ATTRIBUTE, +} = require('../../../../packages/core/admin/ee/server/constants/workflows'); + +const defaultStages = require('../../../../packages/core/admin/ee/server/constants/default-stages.json'); + +const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; + +const productUID = 'api::product.product'; +const model = { + draftAndPublish: true, + pluginOptions: {}, + singularName: 'product', + pluralName: 'products', + displayName: 'Product', + kind: 'collectionType', + attributes: { + name: { + type: 'string', + }, + }, +}; + +describeOnCondition(edition === 'EE')('Review workflows', () => { + const builder = createTestBuilder(); + + const requests = { + public: null, + admin: null, + }; + let strapi; + let hasRW; + let defaultStage; + let secondStage; + let testWorkflow; + + const createEntry = async (uid, data) => { + const { body } = await requests.admin({ + method: 'POST', + url: `/content-manager/collection-types/${uid}`, + body: data, + }); + return body; + }; + + const updateEntry = async (uid, id, data) => { + const { body } = await requests.admin({ + method: 'PUT', + url: `/content-manager/collection-types/${uid}/${id}`, + body: data, + }); + return body; + }; + + const findAll = async (uid) => { + const { body } = await requests.admin({ + method: 'GET', + url: `/content-manager/collection-types/${uid}`, + }); + return body; + }; + + const updateContentType = async (uid, data) => { + const result = await requests.admin({ + method: 'PUT', + url: `/content-type-builder/content-types/${uid}`, + body: data, + }); + + expect(result.statusCode).toBe(201); + }; + + const restart = async () => { + await strapi.destroy(); + strapi = await createStrapiInstance(); + requests.admin = await createAuthRequest({ strapi }); + }; + + beforeAll(async () => { + await builder.addContentTypes([model]).build(); + // eslint-disable-next-line node/no-extraneous-require + hasRW = require('@strapi/strapi/lib/utils/ee').features.isEnabled('review-workflows'); + + strapi = await createStrapiInstance(); + requests.public = createRequest({ strapi }); + requests.admin = await createAuthRequest({ strapi }); + + defaultStage = await strapi.query(STAGE_MODEL_UID).create({ + data: { name: 'Stage' }, + }); + secondStage = await strapi.query(STAGE_MODEL_UID).create({ + data: { name: 'Stage 2' }, + }); + testWorkflow = await strapi.query(WORKFLOW_MODEL_UID).create({ + data: { + uid: 'workflow', + stages: [defaultStage.id, secondStage.id], + }, + }); + }); + + afterAll(async () => { + await strapi.destroy(); + await builder.cleanup(); + }); + + beforeEach(async () => { + testWorkflow = await strapi.query(WORKFLOW_MODEL_UID).update({ + where: { id: testWorkflow.id }, + data: { + uid: 'workflow', + stages: [defaultStage.id, secondStage.id], + }, + }); + await updateContentType(productUID, { + components: [], + contentType: model, + }); + }); + + describe('Get workflows', () => { + test("It shouldn't be available for public", async () => { + const res = await requests.public.get('/admin/review-workflows/workflows'); + + if (hasRW) { + expect(res.status).toBe(401); + } else { + expect(res.status).toBe(404); + expect(Array.isArray(res.body)).toBeFalsy(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const res = await requests.admin.get('/admin/review-workflows/workflows'); + + if (hasRW) { + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBeTruthy(); + // Why 2 workflows ? One added by the test, the other one should be the default workflow added in bootstrap + expect(res.body.data).toHaveLength(2); + } else { + expect(res.status).toBe(404); + expect(Array.isArray(res.body)).toBeFalsy(); + } + }); + }); + + describe('Get one workflow', () => { + test("It shouldn't be available for public", async () => { + const res = await requests.public.get(`/admin/review-workflows/workflows/${testWorkflow.id}`); + + if (hasRW) { + expect(res.status).toBe(401); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const res = await requests.admin.get(`/admin/review-workflows/workflows/${testWorkflow.id}`); + + if (hasRW) { + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Object); + expect(res.body.data).toEqual(testWorkflow); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + }); + + describe('Get workflow stages', () => { + test("It shouldn't be available for public", async () => { + const res = await requests.public.get( + `/admin/review-workflows/workflows/${testWorkflow.id}?populate=stages` + ); + + if (hasRW) { + expect(res.status).toBe(401); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const res = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}?populate=stages` + ); + + if (hasRW) { + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Object); + expect(res.body.data.stages).toBeInstanceOf(Array); + expect(res.body.data.stages).toHaveLength(2); + } else { + expect(res.status).toBe(404); + expect(Array.isArray(res.body)).toBeFalsy(); + } + }); + }); + + describe('Get stages', () => { + test("It shouldn't be available for public", async () => { + const res = await requests.public.get( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages` + ); + + if (hasRW) { + expect(res.status).toBe(401); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const res = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages` + ); + + if (hasRW) { + expect(res.status).toBe(200); + expect(Array.isArray(res.body.data)).toBeTruthy(); + expect(res.body.data).toHaveLength(2); + } else { + expect(res.status).toBe(404); + expect(Array.isArray(res.body)).toBeFalsy(); + } + }); + }); + + describe('Get stage by id', () => { + test("It shouldn't be available for public", async () => { + const res = await requests.public.get( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages/${secondStage.id}` + ); + + if (hasRW) { + expect(res.status).toBe(401); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const res = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages/${secondStage.id}` + ); + + if (hasRW) { + expect(res.status).toBe(200); + expect(res.body.data).toBeInstanceOf(Object); + expect(res.body.data).toEqual(secondStage); + } else { + expect(res.status).toBe(404); + expect(res.body.data).toBeUndefined(); + } + }); + }); + + describe('Replace stages of a workflow', () => { + let stagesUpdateData; + + beforeEach(() => { + stagesUpdateData = [ + defaultStage, + { id: secondStage.id, name: 'new_name' }, + { name: 'new stage' }, + ]; + }); + + test("It shouldn't be available for public", async () => { + const stagesRes = await requests.public.put( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages`, + stagesUpdateData + ); + const workflowRes = await requests.public.get( + `/admin/review-workflows/workflows/${testWorkflow.id}` + ); + + if (hasRW) { + expect(stagesRes.status).toBe(401); + expect(workflowRes.status).toBe(401); + } else { + expect(stagesRes.status).toBe(404); + expect(stagesRes.body.data).toBeUndefined(); + expect(workflowRes.status).toBe(404); + expect(workflowRes.body.data).toBeUndefined(); + } + }); + test('It should be available for every connected users (admin)', async () => { + const stagesRes = await requests.admin.put( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages`, + { body: { data: stagesUpdateData } } + ); + const workflowRes = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}?populate=*` + ); + + if (hasRW) { + expect(stagesRes.status).toBe(200); + expect(stagesRes.body.data).toBeInstanceOf(Object); + expect(stagesRes.body.data.id).toEqual(testWorkflow.id); + expect(workflowRes.status).toBe(200); + expect(workflowRes.body.data).toBeInstanceOf(Object); + expect(workflowRes.body.data.stages).toBeInstanceOf(Array); + expect(workflowRes.body.data.stages[0]).toMatchObject(stagesUpdateData[0]); + expect(workflowRes.body.data.stages[1]).toMatchObject(stagesUpdateData[1]); + expect(workflowRes.body.data.stages[2]).toMatchObject({ + id: expect.any(Number), + ...stagesUpdateData[2], + }); + } else { + expect(stagesRes.status).toBe(404); + expect(stagesRes.body.data).toBeUndefined(); + expect(workflowRes.status).toBe(404); + expect(workflowRes.body.data).toBeUndefined(); + } + }); + test('It should throw an error if trying to delete all stages in a workflow', async () => { + const stagesRes = await requests.admin.put( + `/admin/review-workflows/workflows/${testWorkflow.id}/stages`, + { body: { data: [] } } + ); + const workflowRes = await requests.admin.get( + `/admin/review-workflows/workflows/${testWorkflow.id}?populate=*` + ); + + if (hasRW) { + expect(stagesRes.status).toBe(400); + expect(stagesRes.body.error).toBeDefined(); + expect(stagesRes.body.error.name).toEqual('ApplicationError'); + expect(stagesRes.body.error.message).toBeDefined(); + expect(workflowRes.status).toBe(200); + expect(workflowRes.body.data).toBeInstanceOf(Object); + expect(workflowRes.body.data.stages).toBeInstanceOf(Array); + expect(workflowRes.body.data.stages[0]).toMatchObject({ id: defaultStage.id }); + expect(workflowRes.body.data.stages[1]).toMatchObject({ id: secondStage.id }); + } else { + expect(stagesRes.status).toBe(404); + expect(stagesRes.body.data).toBeUndefined(); + expect(workflowRes.status).toBe(404); + expect(workflowRes.body.data).toBeUndefined(); + } + }); + }); + + describe('Enabling/Disabling review workflows on a content type', () => { + beforeAll(async () => { + await createEntry(productUID, { name: 'Product' }); + await createEntry(productUID, { name: 'Product 1' }); + await createEntry(productUID, { name: 'Product 2' }); + }); + + test('when enabled on a content type, entries of this type should be added to the first stage of the workflow', async () => { + await updateContentType(productUID, { + components: [], + contentType: { ...model, reviewWorkflows: true }, + }); + await restart(); + + const response = await requests.admin({ + method: 'GET', + url: `/content-type-builder/content-types/api::product.product`, + }); + + expect(response.body.data.schema.reviewWorkflows).toBeTruthy(); + + const { + body: { results }, + } = await requests.admin({ + method: 'GET', + url: '/content-manager/collection-types/api::product.product', + }); + + expect(results.length).toEqual(3); + for (let i = 0; i < results.length; i += 1) { + expect(results[i][ENTITY_STAGE_ATTRIBUTE]).toBeDefined(); + } + }); + + test('when disabled entries in the content type should be removed from any workflow stage', async () => { + await updateContentType(productUID, { + components: [], + contentType: { ...model, reviewWorkflows: false }, + }); + + await restart(); + + const response = await requests.admin({ + method: 'GET', + url: `/content-type-builder/content-types/api::product.product`, + }); + expect(response.body.data.schema.reviewWorkflows).toBeFalsy(); + + const { + body: { results }, + } = await requests.admin({ + method: 'GET', + url: '/content-manager/collection-types/api::product.product', + }); + + for (let i = 0; i < results.length; i += 1) { + expect(results[i][ENTITY_STAGE_ATTRIBUTE]).toBeUndefined(); + } + }); + }); + + describe('update a stage on an entity', () => { + describe('Review Workflow is enabled', () => { + beforeAll(async () => { + await updateContentType(productUID, { + components: [], + contentType: { ...model, reviewWorkflows: true }, + }); + await restart(); + }); + test('Should update the accordingly on an entity', async () => { + const entry = await createEntry(productUID, { name: 'Product' }); + + const response = await requests.admin({ + method: 'PUT', + url: `/admin/content-manager/collection-types/${productUID}/${entry.id}/stage`, + body: { + data: { id: secondStage.id }, + }, + }); + + expect(response.status).toEqual(200); + expect(response.body.data[ENTITY_STAGE_ATTRIBUTE]).toEqual( + expect.objectContaining({ id: secondStage.id }) + ); + }); + test('Should throw an error if stage does not exist', async () => { + const entry = await createEntry(productUID, { name: 'Product' }); + + const response = await requests.admin({ + method: 'PUT', + url: `/admin/content-manager/collection-types/${productUID}/${entry.id}/stage`, + body: { + data: { id: 1234 }, + }, + }); + + expect(response.status).toEqual(400); + expect(response.body.error).toBeDefined(); + expect(response.body.error.name).toEqual('ApplicationError'); + expect(response.body.error.message).toEqual('Selected stage does not exist'); + }); + }); + describe('Review Workflow is disabled', () => { + beforeAll(async () => { + await updateContentType(productUID, { + components: [], + contentType: { ...model, reviewWorkflows: false }, + }); + await restart(); + }); + test('Should not update the entity', async () => { + const entry = await createEntry(productUID, { name: 'Product' }); + + const response = await requests.admin({ + method: 'PUT', + url: `/admin/content-manager/collection-types/${productUID}/${entry.id}/stage`, + body: { + data: { id: secondStage.id }, + }, + }); + + expect(response.status).toEqual(400); + expect(response.body.error).toBeDefined(); + expect(response.body.error.name).toBe('ApplicationError'); + }); + }); + }); + + describe('Creating an entity in a review workflow content type', () => { + beforeAll(async () => { + await updateContentType(productUID, { + components: [], + contentType: { ...model, reviewWorkflows: true }, + }); + await restart(); + }); + + test('when review workflows is enabled on a content type, new entries should be added to the first stage of the default workflow', async () => { + const adminResponse = await createEntry(productUID, { name: 'Product' }); + expect(await adminResponse[ENTITY_STAGE_ATTRIBUTE].name).toEqual(defaultStages[0].name); + }); + }); + + //FIXME Flaky test + describe.skip('Deleting a stage when content already exists', () => { + test('When content exists in a review stage and this stage is deleted, the content should be moved to the nearest available stage', async () => { + const products = await findAll(productUID); + + // Move half of the entries to the last stage, + // and the other half to the first stage + await mapAsync(products.results, async (entity) => + updateEntry(productUID, entity.id, { + [ENTITY_STAGE_ATTRIBUTE]: entity.id % 2 ? defaultStage.id : secondStage.id, + }) + ); + + // Delete last stage stage of the default workflow + await requests.admin.put(`/admin/review-workflows/workflows/${testWorkflow.id}/stages`, { + body: { data: [defaultStage] }, + }); + + // Expect the content in these stages to be moved to the nearest available stage + const productsAfter = await findAll(productUID); + for (const entry of productsAfter.results) { + expect(entry[ENTITY_STAGE_ATTRIBUTE].name).toEqual(defaultStage.name); + } + }); + }); +}); diff --git a/docs/docs/core/admin/ee/intro.md b/docs/docs/core/admin/ee/intro.md new file mode 100644 index 0000000000..12a3ff99bb --- /dev/null +++ b/docs/docs/core/admin/ee/intro.md @@ -0,0 +1,17 @@ +--- +title: Introduction +slug: /admin/ee +tags: + - enterprise-edition +--- + +# Admin Enterprise Edition + +This section is an overview of all the features related to the Enterprise Edition in Admin: + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; +import { useCurrentSidebarCategory } from '@docusaurus/theme-common'; + + +``` diff --git a/docs/docs/core/admin/ee/review-workflows.md b/docs/docs/core/admin/ee/review-workflows.md new file mode 100644 index 0000000000..fdd7fdd91f --- /dev/null +++ b/docs/docs/core/admin/ee/review-workflows.md @@ -0,0 +1,140 @@ +--- +title: Review Workflows +slug: /admin/ee/review-workflows +description: Review workflow technical design +tags: + - review-workflows + - implementation + - tech design +--- + +# Review Workflows + +## Summary + +The review workflow feature is only available in the Enterprise Edition. +That is why, in part, it is completely decoupled from the code of the Community Edition. + +The purpose of this feature is to allow users to assign a tag to the various entities of their Strapi project. This tag is called a 'stage' and is available within what we will call a workflow. + +## Detailed backend design + +The Review Workflow feature have been built with one main consideration, to be decoupled from the Community Edition. As so, the implementation can relate a lot to how a plugin would be built. + +All the backend code related to Review Workflow can be found in `packages/core/admin/ee`. +This code is separated into several elements: + +- Two content-types + - _strapi_workflows_: `packages/core/admin/ee/server/content-types/workflow/index.js` + - _strapi_workflows_stages_: `packages/core/admin/ee/server/content-types/workflow-stage/index.js` +- Two controllers + - _workflows_: `packages/core/admin/ee/server/controllers/workflows/index.js` + - _stages_: `packages/core/admin/ee/server/controllers/workflows/stages/index.js` +- One middleware + - _contentTypeMiddleware_: `packages/core/admin/ee/server/middlewares/review-workflows.js` +- Routes + - `packages/core/admin/ee/server/routes/index.js` +- Four services + - _review-workflows_: `packages/core/admin/ee/server/services/review-workflows/review-workflows.js` + - _workflows_: `packages/core/admin/ee/server/services/review-workflows/workflows.js` + - _stages_: `packages/core/admin/ee/server/services/review-workflows/stages.js` + - _metrics_: `packages/core/admin/ee/server/services/review-workflows/metrics.js` +- One decorator + - _EntityService_ decorator: `packages/core/admin/ee/server/services/review-workflows/entity-service-decorator.js` +- One utils file + - _Review workflows utils_: `packages/core/admin/ee/server/utils/review-workflows.js` +- A bootstrap and a register part + - `packages/core/admin/ee/server/bootstrap.js` + - `packages/core/admin/ee/server/register.js` + +### Content types + +#### strapi_workflows + +This content type stores the workflow information and is responsible for holding all the information about stages and their order. In MVP, only one workflow is stored inside the Strapi database. + +#### strapi_workflows_stages + +This content type store the stage information such as its name. + +### Controllers + +#### workflows + +Used to interact with the `strapi_workflows` content-type. + +#### stages + +Used to interact with the `strapi_workflows_stages` content-type. + +### Middlewares + +#### contentTypeMiddleware + +In order to properly manage the options for content-type in the root level of the object, it is necessary to relocate the `reviewWorkflows` option within the `options` object located inside the content-type data. By doing so, we can ensure that all options are consistently organized and easily accessible within their respective data structures. This will also make it simpler to maintain and update the options as needed, providing a more streamlined and efficient workflow for developers working with the system. Therefore, it is recommended to move the reviewWorkflows option to its appropriate location within the options object inside the content-type data before sending it to the admin API. + +### Routes + +The Admin API of the Enterprise Edition includes several routes related to the Review Workflow feature. Here is a list of those routes: + +#### GET `/review-workflows/workflows` + +This route returns a list of all workflows. + +#### GET `/review-workflows/workflows/:id` + +This route returns the details of a specific workflow identified by the id parameter. + +#### GET `/review-workflows/workflows/:workflow_id/stages` + +This route returns a list of all stages associated with a specific workflow identified by the workflow_id parameter. + +#### GET `/review-workflows/workflows/:workflow_id/stages/:id` + +This route returns the details of a specific stage identified by the id parameter and associated with the workflow identified by the workflow_id parameter. + +#### PUT `/review-workflows/workflows/:workflow_id/stages` + +This route updates the stages associated with a specific workflow identified by the workflow_id parameter. The updated stages are passed in the request body. + +#### PUT `/content-manager/(collection|single)-types/:model_uid/:id/stage` + +This route updates the stage of a specific entity identified by the id parameter and belonging to a specific collection identified by the model_uid parameter. The new stage value is passed in the request body. + +### Services + +The Review Workflow feature of the Enterprise Edition includes several services to manipulate workflows and stages. Here is a list of those services: + +#### review-workflows + +This service is used during the bootstrap and register phases of Strapi. Its primary responsibility is to migrate data on entities as needed and add the stage field to the entity schemas. + +#### workflows + +This service is used to manipulate the workflows entities. It provides functionalities to create, retrieve, and update workflows. + +#### stages + +This service is used to manipulate the stages entities and to update stages on other entities. It provides functionalities to create, retrieve, update, and delete stages. + +#### metrics + +This is the telemetry service used to gather information on the usage of this feature. It provides information on the number of workflows and stages created, as well as the frequency of stage updates on entities. + +### Decorators + +#### Entity Service + +The entity service is decorated so that entities can be linked to a default stage upon creation. This allows the entities to be automatically associated with a specific workflow stage when they are created. + +## Alternatives + +The Review Workflow feature is currently included as a core feature within the Strapi repository. However, there has been discussion about potentially moving it to a plugin in the future. While no decision has been made on this subject yet, it is possible that it may happen at some point in the future. + +## Resources + +- https://docs.strapi.io/user-docs/settings/review-workflows +- https://docs.strapi.io/user-docs/content-type-builder/creating-new-content-type#creating-a-new-content-type +- https://docs.strapi.io/user-docs/users-roles-permissions/configuring-administrator-roles#plugins-and-settings +- [Content manager](/content-manager/review-workflows) +- [Content type builder](/content-type-builder/review-workflows) diff --git a/docs/docs/core/admin/intro.md b/docs/docs/core/admin/intro.md new file mode 100644 index 0000000000..123c42af8d --- /dev/null +++ b/docs/docs/core/admin/intro.md @@ -0,0 +1,17 @@ +--- +title: Introduction +slug: /admin +tags: + - admin +--- + +# Admin + +This section is an overview of all the features related to admin: + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; +import { useCurrentSidebarCategory } from '@docusaurus/theme-common'; + + +``` diff --git a/docs/docs/core/content-manager/review-workflows.mdx b/docs/docs/core/content-manager/review-workflows.mdx new file mode 100644 index 0000000000..f762cac565 --- /dev/null +++ b/docs/docs/core/content-manager/review-workflows.mdx @@ -0,0 +1,95 @@ +--- +title: Review Workflows +slug: /content-manager/review-workflows +description: Guide for review workflows in the content-manager. +tags: + - content-manager + - review-workflows +--- + +## Summary + +Review workflows are disabled for all content-types by default and have to be enabled for each of them. More about how to [enable review-workflows for a content-type](/content-type-builder/review-workflows). + +The feature is visible in two locations of the content-manager: + +### List view + +If the feature is enabled for a content-type a new column will show up, displaying the current stage. If no stage was assigned to an entity, +the column is displayed as empty. + +### Edit view + +If the feature is enabled for a content-type the currently selected stage will show up in the information sidebar next to the edit view. Users +can select any other stage of the current workflow. + +Stage assignments are decoupled from entities, meaning that updating an entity won't set the selected stage. Instead the stage select +component will trigger an atomic update using the admin API to assign/ update a stage to the current entity, when a new value is selected. +Because of this decoupling stages **can not be assigned on entity creation** and only after the have been created. + +If no stage was assigned to the current entity the select component displays and error and asks a user to select a stage. + +## Default stage + +By default every entity which is part of a content-type with review workflows enabled, will get the first stage of the attached workflow +assigned upon creation via the **admin API**, **content API** and the **entity Service**. + +### Stage assignments + +The default stage is assigned upon entity creation. In the bootstrap phase of Strapi all entities that do not have a stage assigned +(and are part of a content-type which has the feature enabled) will have the default stage assigned. Initially this was meant as the +migration when the feature is enabled for the first time to ensure all entities, but became also a safety-net for entities +that do not have a stage set. + +### Nullish stages + +Entities which are not created through the admin API, content API or entity service will not have a stage assigned by default (e.g. lifecycle methods). +If entities are created through more low-level ways, developers need to take care to assign a stage individually. + +This means at any place where the UI displays a stage, it has to be prepared to receive `null` and should not crash. + +## List view + +The information which stage is current assigned to an entity is send as part of the content-type response payload for each entity in the attribute `strapi_reviewWorkflows_stage`. +Please see [Data Shapes](/settings/review-workflows#data-shapes) for type definitions. + +```ts +{ + // ... entity attributes + strapi_reviewWorkflows_stage?: Stage | null +} +``` + +`http://localhost:1337/content-manager/content-types` returns whether the feature is enabled for the content-type. `options.reviewWorkflows` is either `true`, `false` or undefined. + +**Note**: Downgrading from EE to CE won't delete the associated review workflow data and `http://localhost:1337/content-manager/content-types` still returns true. The admin app had to +add an additional check if the feature toggle returned in `http://localhost:1337/admin/project-type` is enabled. + +## Edit View + +The information which stage is current assigned to an entity is send as part of the entity response payload in the attribute `strapi_reviewWorkflows_stage`. +Please see [Data Shapes](/settings/review-workflows#data-shapes) for type definitions. + +```ts +{ + // ... entity attributes + strapi_reviewWorkflows_stage?: Stage | null +} +``` + +- `undefined`: the feature is not enabled for this content-type +- `null`: no stage is assigned to the entity + +### Endpoints + +#### `PUT /admin/content-manager/[kind]/[content-type-uid]/[entity-id]/stage` + +Assigns a stage to an entity. + +##### Payload + +```ts +data: { + id: int // assigned stage id +} +``` diff --git a/docs/docs/core/content-type-builder/review-workflows.mdx b/docs/docs/core/content-type-builder/review-workflows.mdx new file mode 100644 index 0000000000..22f6547657 --- /dev/null +++ b/docs/docs/core/content-type-builder/review-workflows.mdx @@ -0,0 +1,38 @@ +--- +title: Review Workflows +slug: /content-type-builder/review-workflows +description: Guide for review workflows in the content-type-builder. +tags: + - content-type-builder + - review-workflows +--- + +## Summary + +By **default review workflows are disabled on all content-types** and users have to enable one workflow +per content-type. This can be achieved in the "Advanced Settings" Tab of the edit content-type +modal. + +Similar to draft & publish review-workflows registers a new input component type called `toggle-review-workflows` +which is used to render the checkbox component. + +**Note**: *Ideally the code should have been placed in the `ee` folder to be +under the enterprise license, but neither the content-type-builder nor the babel-plugin to transpile the ee code had support for this.* + +## Endpoints + +### `PUT /content-type-builder/content-types/[content-type-uid]` + +Toggle review workflows for the content-type. + +#### Payload + +```ts +{ + components: [], + contentType: { + attributes: {}, + reviewWorkflows: boolean + } +} +``` diff --git a/docs/docs/core/content-manager/hooks/use-callback-ref.mdx b/docs/docs/core/helper-plugin/hooks/use-callback-ref.mdx similarity index 90% rename from docs/docs/core/content-manager/hooks/use-callback-ref.mdx rename to docs/docs/core/helper-plugin/hooks/use-callback-ref.mdx index e31fe1ce10..a468cbe06d 100644 --- a/docs/docs/core/content-manager/hooks/use-callback-ref.mdx +++ b/docs/docs/core/helper-plugin/hooks/use-callback-ref.mdx @@ -1,8 +1,7 @@ --- title: useCallbackRef -description: API reference for the useCallbackRef hook in Strapi's Content Manager +description: API reference for the useCallbackRef hook tags: - - content-manager - hooks - refs - callbacks diff --git a/docs/docs/core/settings/intro.mdx b/docs/docs/core/settings/intro.mdx new file mode 100644 index 0000000000..32806bb2d9 --- /dev/null +++ b/docs/docs/core/settings/intro.mdx @@ -0,0 +1,17 @@ +--- +title: Introduction +slug: /settings/intro +tags: + - settings +--- + +# Settings + +This section is an overview of all the features related to Settings: + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; +import { useCurrentSidebarCategory } from '@docusaurus/theme-common'; + + +``` diff --git a/docs/docs/core/settings/review-workflows.mdx b/docs/docs/core/settings/review-workflows.mdx new file mode 100644 index 0000000000..5c0b9acf6e --- /dev/null +++ b/docs/docs/core/settings/review-workflows.mdx @@ -0,0 +1,77 @@ +--- +title: Review Workflows +slug: /settings/review-workflows +description: Guide for review workflows in settings. +tags: + - settings + - review-workflows +--- + +## Summary + +The settings page for review workflows is where users can add and edit stages in any workflow. A stage is a step within +each workflow. It is only **accessible in enterprise mode** and if the read permission `admin::review-workflows.read` is set to `true`. + +Upon mount the settings page injects itself into the global redux store under the namespace `settings_review-workflows`. Redux is +then used for all state management updates on the settings page. `Formik` is used to render and validate the list of stages. It +is integrated with redux, so that all input components are controlled components. + +### Form submission + +The form the wraps workflow stages submits all stages at once, because we expect the number of stages per workflow to be +rather small. Because of this we can simply re-order stages by sending a different order. Every stage that sends a corresponding `id` +attribute will be re-ordered and not created. Stages without an `id` property will be created in the database on submission. + +### Stage deletion + +In case a stage is deleted, all **entities which are connected to that stage are moved to the previous stage**. Because a stage deletion +might have big effects on the database, a confirmation is shown when a stage is up for deletion. + +Changes are only applied if the user hits "Save". It is not possible to remove all stages from a workflow (neither in the UI nor the API). + +### Hooks + +#### `useReviewWorkflows(workflowId?: number)` + +This hook allows to fetch either one (if `workflowId` is passed) or all workflows at once. By default all stages are populated. The +hooks returns a react-query result. This hook is used to fetch a workflow on the settings page and the content-manager edit view. + +The API returns an `array` of workflows. In the first iteration only one workflow is supported, but this is subject to change soon. + +### Data shapes + +```ts +type Stage { + id: int + name: string // max-length: 255 characters + createdAt: Date + updatedAt: Date +} + +type Worklow { + id: int, + stages: Stage[] + createdAt: Date + updatedAt: Date +} +``` + +### Endpoints + +#### `GET /admin/review-workflows/workflows/` + +Returns a list of all workflows. Stages can be populated using `?populate=stages`. + +#### `PUT /admin/review-workflows/workflows/` + +Update workflow stages. + +##### Payload + +```ts +{ + data: Stage[] +} +``` + +**Note**: All stages need to be submitted. Stages without an `id` attribute will be created. The order of stages is persisted in the database. diff --git a/docs/sidebars.js b/docs/sidebars.js index c0f0ba767a..8a7c391a5f 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -16,17 +16,6 @@ const sidebars = { // By default, Docusaurus generates a sidebar from the docs folder structure docs: [ 'index', - { - type: 'category', - label: 'Admin', - items: [ - { - type: 'doc', - label: 'Link Strapi Design System', - id: 'core/admin/link-strapi-design-system', - }, - ], - }, { type: 'category', label: 'Core', @@ -38,7 +27,26 @@ const sidebars = { { type: 'category', label: 'Admin', + link: { + type: 'doc', + id: 'core/admin/intro', + }, items: [ + { + type: 'category', + label: 'Enterprise Edition', + link: { + type: 'doc', + id: 'core/admin/ee/intro', + }, + items: [ + { + type: 'doc', + label: 'Review Workflows', + id: 'core/admin/ee/review-workflows', + }, + ], + }, { type: 'doc', label: 'Link Strapi Design System', @@ -58,11 +66,6 @@ const sidebars = { type: 'category', label: 'Hooks', items: [ - { - type: 'doc', - label: 'useCallbackRef', - id: 'core/content-manager/hooks/use-callback-ref', - }, { type: 'doc', label: 'useDragAndDrop', @@ -75,6 +78,11 @@ const sidebars = { label: 'Relations', id: 'core/content-manager/relations', }, + { + type: 'doc', + label: 'Review Workflows', + id: 'core/content-manager/review-workflows', + }, ], }, { @@ -84,7 +92,13 @@ const sidebars = { type: 'doc', id: 'core/content-type-builder/intro', }, - items: ['example'], + items: [ + { + type: 'doc', + label: 'Review Workflows', + id: 'core/content-type-builder/review-workflows', + }, + ], }, { type: 'category', @@ -120,6 +134,11 @@ const sidebars = { label: 'useAPIErrorHandler', id: 'core/helper-plugin/hooks/use-api-error-handler', }, + { + type: 'doc', + label: 'useCallbackRef', + id: 'core/helper-plugin/hooks/use-callback-ref', + }, { type: 'doc', label: 'useCollator', @@ -170,6 +189,21 @@ const sidebars = { }, ], }, + { + type: 'category', + label: 'Settings', + link: { + type: 'doc', + id: 'core/settings/intro', + }, + items: [ + { + type: 'doc', + label: 'Review Workflows', + id: 'core/settings/review-workflows', + }, + ], + }, { type: 'category', label: 'Utils', diff --git a/examples/getstarted/config/plugins.js b/examples/getstarted/config/plugins.js index a6bb88b407..48ef1edb79 100644 --- a/examples/getstarted/config/plugins.js +++ b/examples/getstarted/config/plugins.js @@ -17,7 +17,7 @@ module.exports = () => ({ documentation: { config: { info: { - version: '2.0.0', + version: '1.0.0', }, }, }, diff --git a/examples/getstarted/config/server.js b/examples/getstarted/config/server.js index d1e015af1e..9071152762 100644 --- a/examples/getstarted/config/server.js +++ b/examples/getstarted/config/server.js @@ -19,4 +19,6 @@ module.exports = ({ env }) => ({ // This only populates relations in all content-manager endpoints populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', true), }, + // ℹ️ http_proxy is the env var used by system to set proxy globally + globalProxy: env('http_proxy'), }); diff --git a/examples/getstarted/package.json b/examples/getstarted/package.json index f1543d185d..fed4eecbf1 100644 --- a/examples/getstarted/package.json +++ b/examples/getstarted/package.json @@ -1,7 +1,7 @@ { "name": "getstarted", "private": true, - "version": "4.9.2", + "version": "4.10.1", "description": "A Strapi application.", "scripts": { "develop": "strapi develop", @@ -13,25 +13,25 @@ }, "dependencies": { "@strapi/icons": "1.6.6", - "@strapi/plugin-color-picker": "4.9.2", - "@strapi/plugin-documentation": "4.9.2", - "@strapi/plugin-graphql": "4.9.2", - "@strapi/plugin-i18n": "4.9.2", - "@strapi/plugin-sentry": "4.9.2", - "@strapi/plugin-users-permissions": "4.9.2", - "@strapi/provider-email-mailgun": "4.9.2", - "@strapi/provider-upload-aws-s3": "4.9.2", - "@strapi/provider-upload-cloudinary": "4.9.2", - "@strapi/strapi": "4.9.2", + "@strapi/plugin-color-picker": "4.10.1", + "@strapi/plugin-documentation": "4.10.1", + "@strapi/plugin-graphql": "4.10.1", + "@strapi/plugin-i18n": "4.10.1", + "@strapi/plugin-sentry": "4.10.1", + "@strapi/plugin-users-permissions": "4.10.1", + "@strapi/provider-email-mailgun": "4.10.1", + "@strapi/provider-upload-aws-s3": "4.10.1", + "@strapi/provider-upload-cloudinary": "4.10.1", + "@strapi/strapi": "4.10.1", "@vscode/sqlite3": "5.1.2", - "better-sqlite3": "8.0.1", + "better-sqlite3": "8.3.0", "lodash": "4.17.21", "mysql": "2.18.1", "mysql2": "3.2.0", "passport-google-oauth2": "0.2.0", "pg": "8.8.0", "react": "^17.0.2", - "react-intl": "6.3.2", + "react-intl": "6.4.1", "sqlite3": "5.1.2" }, "strapi": { diff --git a/examples/getstarted/src/api/address/content-types/address/schema.json b/examples/getstarted/src/api/address/content-types/address/schema.json index 90bfbaa50f..b6534f5759 100755 --- a/examples/getstarted/src/api/address/content-types/address/schema.json +++ b/examples/getstarted/src/api/address/content-types/address/schema.json @@ -9,8 +9,8 @@ "name": "Address" }, "options": { - "draftAndPublish": false, - "comment": "" + "reviewWorkflows": true, + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { diff --git a/examples/getstarted/src/api/category/content-types/category/schema.json b/examples/getstarted/src/api/category/content-types/category/schema.json index c13d00bcdb..48e29ba004 100755 --- a/examples/getstarted/src/api/category/content-types/category/schema.json +++ b/examples/getstarted/src/api/category/content-types/category/schema.json @@ -9,8 +9,8 @@ "name": "Category" }, "options": { - "draftAndPublish": true, - "comment": "" + "reviewWorkflows": true, + "draftAndPublish": true }, "pluginOptions": { "i18n": { diff --git a/examples/kitchensink-ts/package.json b/examples/kitchensink-ts/package.json index dcd0f4feb9..22ddb7afc2 100644 --- a/examples/kitchensink-ts/package.json +++ b/examples/kitchensink-ts/package.json @@ -1,7 +1,7 @@ { "name": "kitchensink-ts", "private": true, - "version": "4.9.2", + "version": "4.10.1", "description": "A Strapi application", "scripts": { "develop": "strapi develop", @@ -10,10 +10,10 @@ "strapi": "strapi" }, "dependencies": { - "@strapi/plugin-i18n": "4.9.2", - "@strapi/plugin-users-permissions": "4.9.2", - "@strapi/strapi": "4.9.2", - "better-sqlite3": "8.0.1" + "@strapi/plugin-i18n": "4.10.1", + "@strapi/plugin-users-permissions": "4.10.1", + "@strapi/strapi": "4.10.1", + "better-sqlite3": "8.3.0" }, "author": { "name": "A Strapi developer" diff --git a/examples/kitchensink/package.json b/examples/kitchensink/package.json index 1919a7533b..14ff7c338c 100644 --- a/examples/kitchensink/package.json +++ b/examples/kitchensink/package.json @@ -1,7 +1,7 @@ { "name": "kitchensink", "private": true, - "version": "4.9.2", + "version": "4.10.1", "description": "A Strapi application.", "scripts": { "develop": "strapi develop", @@ -12,10 +12,10 @@ "strapi": "strapi" }, "dependencies": { - "@strapi/provider-email-mailgun": "4.9.2", - "@strapi/provider-upload-aws-s3": "4.9.2", - "@strapi/provider-upload-cloudinary": "4.9.2", - "@strapi/strapi": "4.9.2", + "@strapi/provider-email-mailgun": "4.10.1", + "@strapi/provider-upload-aws-s3": "4.10.1", + "@strapi/provider-upload-cloudinary": "4.10.1", + "@strapi/strapi": "4.10.1", "lodash": "4.17.21", "mysql": "2.18.1", "passport-google-oauth2": "0.2.0", diff --git a/jest-preset.front.js b/jest-preset.front.js index 438f6bfccb..37dac4fc8d 100644 --- a/jest-preset.front.js +++ b/jest-preset.front.js @@ -5,12 +5,9 @@ const path = require('path'); const IS_EE = process.env.IS_EE === 'true'; const moduleNameMapper = { - '.*\\.(css|less|styl|scss|sass)$': path.join( - __dirname, - 'packages/admin-test-utils/lib/mocks/cssModule.js' - ), + '.*\\.(css|less|styl|scss|sass)$': '@strapi/admin-test-utils/file-mock', '.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico)$': - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/image.js'), + '@strapi/admin-test-utils/file-mock', '^ee_else_ce(/.*)$': IS_EE ? [ path.join(__dirname, 'packages/core/admin/ee/admin$1'), @@ -34,21 +31,9 @@ module.exports = { rootDir: __dirname, moduleNameMapper, testPathIgnorePatterns: ['/node_modules/', '__tests__'], - globalSetup: path.join(__dirname, 'test/config/front/global-setup.js'), - setupFiles: [ - path.join(__dirname, 'packages/admin-test-utils/lib/setup/test-bundler.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/fetch.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/LocalStorageMock.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/IntersectionObserver.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/ResizeObserver.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/windowMatchMedia.js'), - path.join(__dirname, 'packages/admin-test-utils/lib/mocks/mockRangeApi.js'), - ], - setupFilesAfterEnv: [ - path.join(__dirname, '/packages/admin-test-utils/lib/setup/styled-components.js'), - path.join(__dirname, '/packages/admin-test-utils/lib/setup/strapi.js'), - path.join(__dirname, '/packages/admin-test-utils/lib/setup/prop-types.js'), - ], + globalSetup: '@strapi/admin-test-utils/global-setup', + setupFiles: ['@strapi/admin-test-utils/environment'], + setupFilesAfterEnv: ['@strapi/admin-test-utils/after-env'], testEnvironment: 'jsdom', transform: { '^.+\\.js$': [ diff --git a/jest.config.front.js b/jest.config.front.js new file mode 100644 index 0000000000..be0d1bce5f --- /dev/null +++ b/jest.config.front.js @@ -0,0 +1,12 @@ +'use strict'; + +/** @type {import('jest').Config} */ +const config = { + projects: [ + '/packages/plugins/*/jest.config.front.js', + '/packages/core/*/jest.config.front.js', + '/scripts/*/jest.config.front.js', + ], +}; + +module.exports = config; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000000..6ef1408f68 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +'use strict'; + +/** @type {import('jest').Config} */ +const config = { + projects: [ + '/packages/plugins/*/jest.config.js', + '/packages/utils/*/jest.config.js', + '/packages/generators/*/jest.config.js', + '/packages/core/*/jest.config.js', + '/packages/providers/*/jest.config.js', + '/.github/actions/*/jest.config.js', + ], +}; + +module.exports = config; diff --git a/lerna.json b/lerna.json index 35deedccd2..4efa55cc09 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "4.9.2", + "version": "4.10.1", "packages": ["packages/*", "examples/*"], "npmClient": "yarn", "useWorkspaces": true, diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000000..4469ce4c24 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,50 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const findUp = require('find-up'); + +const includes = ['packages', '.github']; + +const root = path.resolve(__dirname); + +function extractPackageName(pkgJsonPath) { + return JSON.parse(fs.readFileSync(pkgJsonPath).toString()).name; +} + +function getLintCommand(files) { + const affectedFolders = new Set(); + + for (const file of files) { + const r = findUp.sync('package.json', { cwd: file }); + const relPath = path.relative(root, r); + + if (includes.some((incl) => relPath.startsWith(incl))) { + affectedFolders.add(r); + } + } + + const affectedPackages = [...affectedFolders].map(extractPackageName); + + if (affectedPackages.length === 0) { + return null; + } + return `nx run-many -t lint -p ${affectedPackages.join()}`; +} + +function getCodeCommands(files) { + const lintCmd = getLintCommand(files); + + const prettierCmd = `prettier --write ${files.join(' ')}`; + + if (lintCmd) { + return [lintCmd, prettierCmd]; + } + + return [prettierCmd]; +} + +module.exports = { + '*.{js,ts}': getCodeCommands, + '*.{md,css,scss,yaml,yml}': ['prettier --write'], +}; diff --git a/nx.json b/nx.json index 9e217a281e..d100fd87c7 100644 --- a/nx.json +++ b/nx.json @@ -29,12 +29,10 @@ "dependsOn": ["^build:ts"] }, "test:unit": { - "inputs": ["default", "{workspaceRoot}/jest-preset.unit.js"], - "dependsOn": ["build:ts"] + "inputs": ["default", "{workspaceRoot}/jest-preset.unit.js"] }, "test:front": { - "inputs": ["default", "{workspaceRoot}/jest-preset.front.js"], - "dependsOn": ["^build"] + "inputs": ["default", "{workspaceRoot}/jest-preset.front.js"] }, "lint": { "inputs": [ @@ -44,8 +42,7 @@ "{projectRoot}/.eslintignore", "{projectRoot}/tsconfig.eslint.json", "{workspaceRoot}/packages/utils/eslint-config-custom/**/*" - ], - "dependsOn": ["build:ts"] + ] } }, "tasksRunnerOptions": { diff --git a/package.json b/package.json index f21269cb9a..a4a24b6da3 100644 --- a/package.json +++ b/package.json @@ -45,37 +45,36 @@ "format:other": "yarn prettier:other --write", "prettier:code": "prettier --cache --cache-strategy content \"**/*.{js,ts}\"", "prettier:other": "prettier --cache --cache-strategy content \"**/*.{md,css,scss,yaml,yml}\"", - "test:front": "cross-env IS_EE=true nx run-many --target=test:front --nx-ignore-cycles", - "test:front:watch": "cross-env IS_EE=true nx run-many --target=test:front:watch --nx-ignore-cycles", - "test:front:update": "yarn test:front -u", - "test:front:ce": "cross-env IS_EE=false nx run-many --target=test:front --nx-ignore-cycles", - "test:front:watch:ce": "cross-env IS_EE=false nx run-many --target=test:front:watch --nx-ignore-cycles", + "test:front:all": "cross-env IS_EE=true nx run-many --target=test:front --nx-ignore-cycles", + "test:front": "cross-env IS_EE=true jest --config jest.config.front.js", + "test:front:watch": "cross-env IS_EE=true run test:front --watch", + "test:front:update": "run test:front -u", + "test:front:all:ce": "cross-env IS_EE=false nx run-many --target=test:front:ce --nx-ignore-cycles", + "test:front:ce": "cross-env IS_EE=false run test:front", + "test:front:watch:ce": "cross-env IS_EE=false run test:front --watch", "test:front:update:ce": "yarn test:front:ce -u", - "test:unit": "nx run-many --target=test:unit --nx-ignore-cycles", - "test:unit:watch": "nx run-many --target=test:unit:watch --nx-ignore-cycles", + "test:unit:all": "nx run-many --target=test:unit --nx-ignore-cycles", + "test:unit": "jest --config jest.config.js", + "test:unit:watch": "run test:unit --watch", "test:api": "node test/api.js", "test:generate-app": "node test/create-test-app.js", "doc:api": "node scripts/open-api/serve.js" }, - "lint-staged": { - "*.{js,ts,md,css,scss,yaml,yml}": [ - "prettier --write" - ] - }, "devDependencies": { "@babel/core": "^7.20.12", "@babel/eslint-parser": "^7.19.1", "@babel/preset-react": "7.18.6", + "@strapi/admin-test-utils": "workspace:*", "@strapi/eslint-config": "0.1.2", "@swc/cli": "0.1.62", "@swc/core": "1.3.37", "@swc/jest": "0.2.24", - "@typescript-eslint/eslint-plugin": "^5.55.0", - "@typescript-eslint/parser": "5.43.0", + "@typescript-eslint/eslint-plugin": "5.59.1", + "@typescript-eslint/parser": "5.59.1", "babel-eslint": "10.1.0", "chalk": "4.1.2", "chokidar": "3.5.3", - "core-js": "3.28.0", + "core-js": "3.30.1", "cross-env": "7.0.3", "dotenv": "14.2.0", "eslint": "8.27.0", @@ -90,6 +89,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "execa": "1.0.0", + "find-up": "5.0.0", "fs-extra": "10.1.0", "get-port": "5.1.1", "glob": "7.2.3", @@ -97,7 +97,7 @@ "inquirer": "8.2.5", "jest": "29.0.3", "jest-circus": "29.0.3", - "jest-cli": "29.0.3", + "jest-cli": "29.5.0", "jest-environment-jsdom": "29.0.3", "jest-watch-typeahead": "2.2.2", "lerna": "6.5.1", @@ -115,7 +115,7 @@ "supertest": "6.3.3", "ts-jest": "29.0.3", "typedoc": "0.23.26", - "typescript": "4.6.2", + "typescript": "5.0.4", "yargs": "17.6.0" }, "engines": { diff --git a/packages/admin-test-utils/.eslintignore b/packages/admin-test-utils/.eslintignore index 1723d82cf5..0ea0ae631d 100644 --- a/packages/admin-test-utils/.eslintignore +++ b/packages/admin-test-utils/.eslintignore @@ -1,2 +1,3 @@ node_modules/ +dist/ .eslintrc.js diff --git a/packages/admin-test-utils/.eslintrc.js b/packages/admin-test-utils/.eslintrc.js index 530684a539..a1dca8d1af 100644 --- a/packages/admin-test-utils/.eslintrc.js +++ b/packages/admin-test-utils/.eslintrc.js @@ -1,7 +1,4 @@ module.exports = { root: true, - extends: ['custom/back'], - rules: { - 'import/no-extraneous-dependencies': 'off', - }, + extends: ['custom/typescript'], }; diff --git a/packages/admin-test-utils/.gitignore b/packages/admin-test-utils/.gitignore new file mode 100755 index 0000000000..9585e32ced --- /dev/null +++ b/packages/admin-test-utils/.gitignore @@ -0,0 +1,99 @@ +############################ +# OS X +############################ + +.DS_Store +.AppleDouble +.LSOverride +Icon +.Spotlight-V100 +.Trashes +._* + +############################ +# Linux +############################ + +*~ + +############################ +# Windows +############################ + +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msm +*.msp + +############################ +# Packages +############################ + +*.7z +*.csv +*.dat +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip +*.com +*.class +*.dll +*.exe +*.o +*.seed +*.so +*.swo +*.swp +*.swn +*.swm +*.out +*.pid + +############################ +# Logs and databases +############################ + +.tmp +*.log +*.sql +*.sqlite + +############################ +# Misc. +############################ + +*# +.idea +nbproject + +############################ +# Node.js +############################ + +lib-cov +lcov.info +pids +logs +results +build +node_modules +.node_history +package-lock.json + +############################ +# Tests +############################ + +testApp +coverage + +dist/ +docs/ diff --git a/packages/admin-test-utils/LICENSE b/packages/admin-test-utils/LICENSE new file mode 100644 index 0000000000..db018546b5 --- /dev/null +++ b/packages/admin-test-utils/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015-present Strapi Solutions SAS + +Portions of the Strapi software are licensed as follows: + +* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE". + +* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below. + +MIT Expat License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/generators/app/lib/resources/files/js/database/migrations/.gitkeep b/packages/admin-test-utils/README.md similarity index 100% rename from packages/generators/app/lib/resources/files/js/database/migrations/.gitkeep rename to packages/admin-test-utils/README.md diff --git a/packages/admin-test-utils/custom.d.ts b/packages/admin-test-utils/custom.d.ts new file mode 100644 index 0000000000..ac37bd91b2 --- /dev/null +++ b/packages/admin-test-utils/custom.d.ts @@ -0,0 +1,15 @@ +export {}; + +declare global { + interface Window { + strapi: { + backendURL: string; + isEE: boolean; + features: { + SSO: 'sso'; + isEnabled: (featureName?: string) => boolean; + }; + projectType: string; + }; + } +} diff --git a/packages/admin-test-utils/lib/fixtures/index.js b/packages/admin-test-utils/lib/fixtures/index.js deleted file mode 100644 index 9a1e1b040f..0000000000 --- a/packages/admin-test-utils/lib/fixtures/index.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -const adminPermissions = require('./permissions/admin-permissions'); -const cmPermissions = require('./permissions/content-manager-permissions'); -const ctbPermissions = require('./permissions/content-type-builder-permissions'); -const store = require('./store'); - -const permissions = [...adminPermissions, ...cmPermissions, ...ctbPermissions]; - -module.exports = { - adminPermissions, - cmPermissions, - ctbPermissions, - permissions, - store, -}; diff --git a/packages/admin-test-utils/lib/index.js b/packages/admin-test-utils/lib/index.js deleted file mode 100644 index 6fb0644136..0000000000 --- a/packages/admin-test-utils/lib/index.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -const fixtures = require('./fixtures'); - -module.exports = { - fixtures, -}; diff --git a/packages/admin-test-utils/lib/mocks/IntersectionObserver.js b/packages/admin-test-utils/lib/mocks/IntersectionObserver.js deleted file mode 100644 index aef6fb7c47..0000000000 --- a/packages/admin-test-utils/lib/mocks/IntersectionObserver.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -class IntersectionObserverMock { - constructor() { - this.root = null; - this.rootMargin = ''; - this.thresholds = []; - this.disconnect = () => null; - this.observe = () => null; - this.takeRecords = () => []; - this.unobserve = () => null; - } -} - -global.IntersectionObserver = IntersectionObserverMock; diff --git a/packages/admin-test-utils/lib/mocks/LocalStorageMock.js b/packages/admin-test-utils/lib/mocks/LocalStorageMock.js deleted file mode 100644 index 701520ac77..0000000000 --- a/packages/admin-test-utils/lib/mocks/LocalStorageMock.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict'; - -class LocalStorageMock { - constructor() { - this.store = new Map(); - } - - clear() { - this.store.clear(); - } - - getItem(key) { - /** - * We return null to avoid returning `undefined` - * because `undefined` is not a valid JSON value. - */ - return this.store.get(key) ?? null; - } - - setItem(key, value) { - this.store.set(key, String(value)); - } - - removeItem(key) { - this.store.delete(key); - } - - get length() { - return this.store.size; - } -} - -// eslint-disable-next-line no-undef -Object.defineProperty(window, 'localStorage', { - writable: true, - value: new LocalStorageMock(), -}); diff --git a/packages/admin-test-utils/lib/mocks/ResizeObserver.js b/packages/admin-test-utils/lib/mocks/ResizeObserver.js deleted file mode 100644 index da84baab08..0000000000 --- a/packages/admin-test-utils/lib/mocks/ResizeObserver.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -class ResizeObserverMock { - constructor() { - this.disconnect = () => null; - this.observe = () => null; - this.unobserve = () => null; - } -} - -global.ResizeObserver = ResizeObserverMock; diff --git a/packages/admin-test-utils/lib/mocks/cssModule.js b/packages/admin-test-utils/lib/mocks/cssModule.js deleted file mode 100644 index 83bf2e2338..0000000000 --- a/packages/admin-test-utils/lib/mocks/cssModule.js +++ /dev/null @@ -1 +0,0 @@ -// module.exports = 'CSS_MODULE'; diff --git a/packages/admin-test-utils/lib/mocks/fetch.js b/packages/admin-test-utils/lib/mocks/fetch.js deleted file mode 100644 index 41c2e892f8..0000000000 --- a/packages/admin-test-utils/lib/mocks/fetch.js +++ /dev/null @@ -1,4 +0,0 @@ -'use strict'; - -// Required as long as we are running tests on node@14 and node@16 -require('whatwg-fetch'); diff --git a/packages/admin-test-utils/lib/mocks/image.js b/packages/admin-test-utils/lib/mocks/image.js deleted file mode 100644 index ffc07df9a5..0000000000 --- a/packages/admin-test-utils/lib/mocks/image.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -module.exports = 'IMAGE_MOCK'; diff --git a/packages/admin-test-utils/lib/mocks/mockRangeApi.js b/packages/admin-test-utils/lib/mocks/mockRangeApi.js deleted file mode 100644 index d222d778fe..0000000000 --- a/packages/admin-test-utils/lib/mocks/mockRangeApi.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-undef */ - -'use strict'; - -// Codemirror inner dependency, reference: https://github.com/jsdom/jsdom/issues/3002 -// Otherwise it throws: TypeError: range(...).getBoundingClientRect is not a function - -document.createRange = () => { - const range = new Range(); - range.getClientRects = jest.fn(() => ({ - item: () => null, - length: 0, - })); - - return range; -}; diff --git a/packages/admin-test-utils/lib/mocks/windowMatchMedia.js b/packages/admin-test-utils/lib/mocks/windowMatchMedia.js deleted file mode 100644 index 2d384f79ca..0000000000 --- a/packages/admin-test-utils/lib/mocks/windowMatchMedia.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -global.window.matchMedia = jest.fn(() => false); diff --git a/packages/admin-test-utils/lib/setup/prop-types.js b/packages/admin-test-utils/lib/setup/prop-types.js deleted file mode 100644 index 17928263b9..0000000000 --- a/packages/admin-test-utils/lib/setup/prop-types.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -const { format } = require('util'); - -const originalConsoleError = console.error; - -beforeEach(() => { - console.error = (...args) => { - originalConsoleError(...args); - - const message = format(...args); - - if (/(Invalid prop|Failed prop type)/gi.test(message)) { - throw new Error(message); - } - }; -}); - -afterEach(() => { - console.error = originalConsoleError; -}); diff --git a/packages/admin-test-utils/lib/setup/strapi.js b/packages/admin-test-utils/lib/setup/strapi.js deleted file mode 100644 index ae36c8d04d..0000000000 --- a/packages/admin-test-utils/lib/setup/strapi.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; - -/** - * - * Strapi - * This file allow to mock any key that is in the global strapi variable - * - */ - -// FIXME create a better jest setup -require('@testing-library/jest-dom/extend-expect'); - -global.process.env.ADMIN_PATH = '/admin/'; - -global.strapi = { - backendURL: 'http://localhost:1337', - isEE: false, - features: { - SSO: 'sso', - isEnabled: () => false, - }, - projectType: 'Community', -}; - -global.prompt = jest.fn(); - -global.URL.createObjectURL = (file) => `http://localhost:4000/assets/${file.name}`; diff --git a/packages/admin-test-utils/lib/setup/styled-components.js b/packages/admin-test-utils/lib/setup/styled-components.js deleted file mode 100644 index 5a13a5c611..0000000000 --- a/packages/admin-test-utils/lib/setup/styled-components.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -require('jest-styled-components'); diff --git a/packages/admin-test-utils/lib/setup/test-bundler.js b/packages/admin-test-utils/lib/setup/test-bundler.js deleted file mode 100644 index 6470e5a794..0000000000 --- a/packages/admin-test-utils/lib/setup/test-bundler.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; - -const noop = () => {}; -// eslint-disable-next-line no-undef -Object.defineProperty(window, 'scrollTo', { value: noop, writable: true }); diff --git a/packages/admin-test-utils/package.json b/packages/admin-test-utils/package.json index 24ee4b5af5..586af47ba8 100644 --- a/packages/admin-test-utils/package.json +++ b/packages/admin-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/admin-test-utils", - "version": "4.9.2", + "version": "4.10.1", "private": true, "description": "Test utilities for the Strapi administration panel", "license": "MIT", @@ -16,21 +16,43 @@ "url": "https://strapi.io" } ], - "main": "lib/index.js", - "devDependencies": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js" + }, + "./after-env": { + "require": "./dist/after-env.js" + }, + "./environment": { + "require": "./dist/environment.js" + }, + "./file-mock": { + "require": "./dist/file-mock.js" + }, + "./global-setup": { + "require": "./dist/global-setup.js" + } + }, + "dependencies": { + "@juggle/resize-observer": "3.4.0", "@testing-library/jest-dom": "5.16.5", - "jest-styled-components": "7.1.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-is": "^17.0.2", + "jest-styled-components": "7.1.1" + }, + "devDependencies": { + "eslint-config-custom": "4.10.1", "redux": "^4.2.1", - "styled-components": "5.3.3", - "whatwg-fetch": "3.6.2" + "tsconfig": "4.10.1" }, "peerDependencies": { "redux": "^4.2.1" }, "scripts": { + "build": "run -T tsc", + "build:ts": "run -T tsc", + "watch": "run -T tsc -w --preserveWatchOutput", + "clean": "run -T rimraf ./dist", "lint": "run -T eslint ." }, "engines": { diff --git a/packages/admin-test-utils/src/after-env.ts b/packages/admin-test-utils/src/after-env.ts new file mode 100644 index 0000000000..c488bddf0d --- /dev/null +++ b/packages/admin-test-utils/src/after-env.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +import 'jest-styled-components'; diff --git a/packages/admin-test-utils/src/environment.ts b/packages/admin-test-utils/src/environment.ts new file mode 100644 index 0000000000..c681539450 --- /dev/null +++ b/packages/admin-test-utils/src/environment.ts @@ -0,0 +1,165 @@ +import { ResizeObserver } from '@juggle/resize-observer'; +import { format } from 'util'; + +/* ------------------------------------------------------------------------------------------------- + * IntersectionObserver + * -----------------------------------------------------------------------------------------------*/ + +const mockIntersectionObserver = jest.fn(); +mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, +}); +window.IntersectionObserver = mockIntersectionObserver; + +/* ------------------------------------------------------------------------------------------------- + * ResizeObserver + * -----------------------------------------------------------------------------------------------*/ + +window.ResizeObserver = ResizeObserver; + +/* ------------------------------------------------------------------------------------------------- + * ResizeObserver + * -----------------------------------------------------------------------------------------------*/ + +/** + * If there's a prop type error then we want to throw an + * error so that the test fails. + * + * NOTE: This can be removed once we move to a typescript + * setup & we throw tests on type errors. + */ + +const error = console.error; +window.console = { + ...window.console, + error(...args: any[]) { + error(...args); + + const message = format(...args); + + if (/(Invalid prop|Failed prop type)/gi.test(message)) { + throw new Error(message); + } + }, +}; + +/* ------------------------------------------------------------------------------------------------- + * Strapi + * -----------------------------------------------------------------------------------------------*/ + +window.strapi = { + backendURL: 'http://localhost:1337', + isEE: false, + features: { + SSO: 'sso', + isEnabled: () => false, + }, + projectType: 'Community', +}; + +/* ------------------------------------------------------------------------------------------------- + * matchMedia + * -----------------------------------------------------------------------------------------------*/ + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + /** + * @deprecated + */ + addListener: jest.fn(), + /** + * @deprecated + */ + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +/* ------------------------------------------------------------------------------------------------- + * scrollTo + * -----------------------------------------------------------------------------------------------*/ + +Object.defineProperty(window, 'scrollTo', { + writable: true, + value: jest.fn(), +}); + +/* ------------------------------------------------------------------------------------------------- + * prompt + * -----------------------------------------------------------------------------------------------*/ + +Object.defineProperty(window, 'prompt', { + writable: true, + value: jest.fn(), +}); + +/* ------------------------------------------------------------------------------------------------- + * URL + * -----------------------------------------------------------------------------------------------*/ + +window.URL.createObjectURL = jest + .fn() + .mockImplementation((file) => `http://localhost:4000/assets/${file.name}`); + +/* ------------------------------------------------------------------------------------------------- + * createRange + * -----------------------------------------------------------------------------------------------*/ + +document.createRange = () => { + const range = new Range(); + range.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + })); + + return range; +}; + +/* ------------------------------------------------------------------------------------------------- + * localStorage + * -----------------------------------------------------------------------------------------------*/ + +class LocalStorageMock { + store: Map; + + constructor() { + this.store = new Map(); + } + + clear() { + this.store.clear(); + } + + getItem(key: string) { + /** + * We return null to avoid returning `undefined` + * because `undefined` is not a valid JSON value. + */ + return this.store.get(key) ?? null; + } + + setItem(key: string, value: unknown) { + this.store.set(key, String(value)); + } + + removeItem(key: string) { + this.store.delete(key); + } + + get length() { + return this.store.size; + } +} + +Object.defineProperty(window, 'localStorage', { + writable: true, + value: new LocalStorageMock(), +}); diff --git a/packages/admin-test-utils/src/file-mock.ts b/packages/admin-test-utils/src/file-mock.ts new file mode 100644 index 0000000000..112fb2e104 --- /dev/null +++ b/packages/admin-test-utils/src/file-mock.ts @@ -0,0 +1 @@ +export default 'IMAGE_MOCK'; diff --git a/packages/admin-test-utils/lib/fixtures/collectionTypes/address.js b/packages/admin-test-utils/src/fixtures/collection-types/address.ts similarity index 99% rename from packages/admin-test-utils/lib/fixtures/collectionTypes/address.js rename to packages/admin-test-utils/src/fixtures/collection-types/address.ts index 72b6f75710..82a1977def 100644 --- a/packages/admin-test-utils/lib/fixtures/collectionTypes/address.js +++ b/packages/admin-test-utils/src/fixtures/collection-types/address.ts @@ -1,6 +1,4 @@ -'use strict'; - -const addressCT = { +const address = { uid: 'api::address.address', settings: { bulkable: true, @@ -421,4 +419,6 @@ const addressCT = { }, }; -module.exports = addressCT; +type Address = typeof address; + +export { address, Address }; diff --git a/packages/admin-test-utils/src/fixtures/collection-types/index.ts b/packages/admin-test-utils/src/fixtures/collection-types/index.ts new file mode 100644 index 0000000000..d19aefed5f --- /dev/null +++ b/packages/admin-test-utils/src/fixtures/collection-types/index.ts @@ -0,0 +1,3 @@ +import { address, Address } from './address'; + +export { Address, address }; diff --git a/packages/admin-test-utils/src/fixtures/index.ts b/packages/admin-test-utils/src/fixtures/index.ts new file mode 100644 index 0000000000..8876e21430 --- /dev/null +++ b/packages/admin-test-utils/src/fixtures/index.ts @@ -0,0 +1,6 @@ +import store from './store'; + +export * as collectionTypes from './collection-types'; +export * as metaData from './meta-data'; +export * as permissions from './permissions'; +export { store }; diff --git a/packages/admin-test-utils/lib/fixtures/metaData/address.js b/packages/admin-test-utils/src/fixtures/meta-data/address.ts similarity index 97% rename from packages/admin-test-utils/lib/fixtures/metaData/address.js rename to packages/admin-test-utils/src/fixtures/meta-data/address.ts index c1006244bb..31ee936f77 100644 --- a/packages/admin-test-utils/lib/fixtures/metaData/address.js +++ b/packages/admin-test-utils/src/fixtures/meta-data/address.ts @@ -1,6 +1,4 @@ -'use strict'; - -const addressMetaData = { +const address = { id: { edit: {}, list: { label: 'Id', searchable: true, sortable: true } }, postal_coder: { edit: { @@ -98,4 +96,6 @@ const addressMetaData = { }, }; -module.exports = addressMetaData; +type Address = typeof address; + +export { address, Address }; diff --git a/packages/admin-test-utils/src/fixtures/meta-data/index.ts b/packages/admin-test-utils/src/fixtures/meta-data/index.ts new file mode 100644 index 0000000000..d19aefed5f --- /dev/null +++ b/packages/admin-test-utils/src/fixtures/meta-data/index.ts @@ -0,0 +1,3 @@ +import { address, Address } from './address'; + +export { Address, address }; diff --git a/packages/admin-test-utils/lib/fixtures/permissions/admin-permissions.js b/packages/admin-test-utils/src/fixtures/permissions/admin-permissions.ts similarity index 95% rename from packages/admin-test-utils/lib/fixtures/permissions/admin-permissions.js rename to packages/admin-test-utils/src/fixtures/permissions/admin-permissions.ts index 939f028a6d..2d080420ec 100644 --- a/packages/admin-test-utils/lib/fixtures/permissions/admin-permissions.js +++ b/packages/admin-test-utils/src/fixtures/permissions/admin-permissions.ts @@ -1,6 +1,4 @@ -'use strict'; - -const adminPermissions = [ +const admin = [ { id: 169, action: 'admin::provider-login.read', @@ -108,4 +106,6 @@ const adminPermissions = [ }, ]; -module.exports = adminPermissions; +type Admin = typeof admin; + +export { admin, Admin }; diff --git a/packages/admin-test-utils/lib/fixtures/permissions/content-manager-permissions.js b/packages/admin-test-utils/src/fixtures/permissions/content-manager-permissions.ts similarity index 90% rename from packages/admin-test-utils/lib/fixtures/permissions/content-manager-permissions.js rename to packages/admin-test-utils/src/fixtures/permissions/content-manager-permissions.ts index 11e0d97926..f1cbd35284 100644 --- a/packages/admin-test-utils/lib/fixtures/permissions/content-manager-permissions.js +++ b/packages/admin-test-utils/src/fixtures/permissions/content-manager-permissions.ts @@ -1,6 +1,4 @@ -'use strict'; - -const cmPermissions = [ +const contentManager = [ { id: 2817, action: 'plugin::content-manager.single-types.configure-view', @@ -60,4 +58,6 @@ const cmPermissions = [ }, ]; -module.exports = cmPermissions; +type ContentManager = typeof contentManager; + +export { contentManager, ContentManager }; diff --git a/packages/admin-test-utils/lib/fixtures/permissions/content-type-builder-permissions.js b/packages/admin-test-utils/src/fixtures/permissions/content-type-builder-permissions.ts similarity index 84% rename from packages/admin-test-utils/lib/fixtures/permissions/content-type-builder-permissions.js rename to packages/admin-test-utils/src/fixtures/permissions/content-type-builder-permissions.ts index 84b5450eb0..76752b40b5 100644 --- a/packages/admin-test-utils/lib/fixtures/permissions/content-type-builder-permissions.js +++ b/packages/admin-test-utils/src/fixtures/permissions/content-type-builder-permissions.ts @@ -1,6 +1,4 @@ -'use strict'; - -const ctbPermissions = [ +const contentTypeBuilder = [ { id: 2820, action: 'plugin::content-type-builder.read', @@ -39,4 +37,6 @@ const ctbPermissions = [ }, ]; -module.exports = ctbPermissions; +type ContentTypeBuilder = typeof contentTypeBuilder; + +export { contentTypeBuilder, ContentTypeBuilder }; diff --git a/packages/admin-test-utils/src/fixtures/permissions/index.ts b/packages/admin-test-utils/src/fixtures/permissions/index.ts new file mode 100644 index 0000000000..7eda9a85b8 --- /dev/null +++ b/packages/admin-test-utils/src/fixtures/permissions/index.ts @@ -0,0 +1,24 @@ +/** + * TODO: These types could be done better, since they're mock data + * for user-permissions plugin it might be better to extract them + * from that package and use them here. + */ + +import { admin, Admin } from './admin-permissions'; +import { contentManager, ContentManager } from './content-manager-permissions'; +import { contentTypeBuilder, ContentTypeBuilder } from './content-type-builder-permissions'; + +const allPermissions = [...admin, ...contentManager, ...contentTypeBuilder]; + +type AdminPermissions = typeof allPermissions; + +export { + admin, + Admin, + contentManager, + ContentManager, + contentTypeBuilder, + ContentTypeBuilder, + allPermissions, + AdminPermissions, +}; diff --git a/packages/admin-test-utils/lib/fixtures/store/index.js b/packages/admin-test-utils/src/fixtures/store/index.ts similarity index 91% rename from packages/admin-test-utils/lib/fixtures/store/index.js rename to packages/admin-test-utils/src/fixtures/store/index.ts index 3d304fd3ee..0a2e0d5a45 100644 --- a/packages/admin-test-utils/lib/fixtures/store/index.js +++ b/packages/admin-test-utils/src/fixtures/store/index.ts @@ -1,6 +1,4 @@ -'use strict'; - -const { combineReducers, createStore } = require('redux'); +import { combineReducers, createStore } from 'redux'; const reducers = { admin_app: jest.fn(() => ({ status: 'init' })), @@ -37,7 +35,7 @@ const reducers = { const store = createStore(combineReducers(reducers)); -module.exports = { +export default { store, state: store.getState(), }; diff --git a/packages/admin-test-utils/src/global-setup.ts b/packages/admin-test-utils/src/global-setup.ts new file mode 100644 index 0000000000..94261d1fe3 --- /dev/null +++ b/packages/admin-test-utils/src/global-setup.ts @@ -0,0 +1,7 @@ +const globalSetup = async () => { + process.env.TZ = 'UTC'; + process.env.LANG = 'en_US.UTF-8'; + process.env.ADMIN_PATH = '/admin/'; +}; + +export default globalSetup; diff --git a/packages/admin-test-utils/src/index.ts b/packages/admin-test-utils/src/index.ts new file mode 100644 index 0000000000..c3d1f372dd --- /dev/null +++ b/packages/admin-test-utils/src/index.ts @@ -0,0 +1 @@ +export * as fixtures from './fixtures'; diff --git a/packages/admin-test-utils/tsconfig.eslint.json b/packages/admin-test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..a8e84f79ac --- /dev/null +++ b/packages/admin-test-utils/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + }, +} diff --git a/packages/admin-test-utils/tsconfig.json b/packages/admin-test-utils/tsconfig.json new file mode 100644 index 0000000000..18b66b12e7 --- /dev/null +++ b/packages/admin-test-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "./custom.d.ts"], + "exclude": ["node_modules", "**/__tests__/**"] +} diff --git a/packages/cli/create-strapi-app/package.json b/packages/cli/create-strapi-app/package.json index 4e4d05f703..a1f0dac20d 100644 --- a/packages/cli/create-strapi-app/package.json +++ b/packages/cli/create-strapi-app/package.json @@ -1,9 +1,9 @@ { "name": "create-strapi-app", - "version": "4.9.2", + "version": "4.10.1", "description": "Generate a new Strapi application.", "dependencies": { - "@strapi/generate-new": "4.9.2", + "@strapi/generate-new": "4.10.1", "commander": "8.3.0", "inquirer": "8.2.5" }, @@ -49,8 +49,8 @@ "lint": "run -T eslint ." }, "devDependencies": { - "eslint-config-custom": "4.9.2", - "tsconfig": "4.9.2" + "eslint-config-custom": "4.10.1", + "tsconfig": "4.10.1" }, "engines": { "node": ">=14.19.1 <=18.x.x", diff --git a/packages/cli/create-strapi-starter/package.json b/packages/cli/create-strapi-starter/package.json index 2e4a4d527f..9e899d78f5 100644 --- a/packages/cli/create-strapi-starter/package.json +++ b/packages/cli/create-strapi-starter/package.json @@ -1,6 +1,6 @@ { "name": "create-strapi-starter", - "version": "4.9.2", + "version": "4.10.1", "description": "Generate a new Strapi application.", "keywords": [ "create-strapi-starter", @@ -44,7 +44,7 @@ "lint": "run -T eslint ." }, "dependencies": { - "@strapi/generate-new": "4.9.2", + "@strapi/generate-new": "4.10.1", "chalk": "4.1.2", "ci-info": "3.8.0", "commander": "8.3.0", @@ -54,8 +54,8 @@ "ora": "5.4.1" }, "devDependencies": { - "eslint-config-custom": "4.9.2", - "tsconfig": "4.9.2" + "eslint-config-custom": "4.10.1", + "tsconfig": "4.10.1" }, "engines": { "node": ">=14.19.1 <=18.x.x", diff --git a/packages/cli/create-strapi-starter/src/index.d.ts b/packages/cli/create-strapi-starter/src/index.d.ts deleted file mode 100644 index 00494af403..0000000000 --- a/packages/cli/create-strapi-starter/src/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@strapi/generate-new'; diff --git a/packages/cli/create-strapi-starter/src/utils/build-starter.ts b/packages/cli/create-strapi-starter/src/utils/build-starter.ts index 09a9a3a605..4200fde750 100644 --- a/packages/cli/create-strapi-starter/src/utils/build-starter.ts +++ b/packages/cli/create-strapi-starter/src/utils/build-starter.ts @@ -104,7 +104,7 @@ export default async function buildStarter( { projectName, starter }: { projectName: string; starter: string }, program: Program ) { - const hasYarnInstalled = await hasYarn(); + const hasYarnInstalled = hasYarn(); const { isLocalStarter, starterPath, starterParentPath, starterPackageInfo } = await getStarterInfo(starter, { useYarn: hasYarnInstalled }); diff --git a/packages/cli/create-strapi-starter/src/utils/has-yarn.ts b/packages/cli/create-strapi-starter/src/utils/has-yarn.ts index 8a86a2ad0f..cf3f49bf92 100644 --- a/packages/cli/create-strapi-starter/src/utils/has-yarn.ts +++ b/packages/cli/create-strapi-starter/src/utils/has-yarn.ts @@ -1,8 +1,8 @@ import execa from 'execa'; -export default async function hasYarn() { +export default function hasYarn() { try { - const { exitCode } = await execa.commandSync('yarn --version', { shell: true }); + const { exitCode } = execa.commandSync('yarn --version', { shell: true }); if (exitCode === 0) return true; } catch (err) { diff --git a/packages/core/admin/admin/src/components/AuthenticatedApp/index.js b/packages/core/admin/admin/src/components/AuthenticatedApp/index.js index a848486928..f8465ffcd4 100644 --- a/packages/core/admin/admin/src/components/AuthenticatedApp/index.js +++ b/packages/core/admin/admin/src/components/AuthenticatedApp/index.js @@ -1,9 +1,9 @@ -import React, { useMemo, useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; // TODO: DS add loader import { auth, LoadingIndicatorPage, - AppInfosContext, + AppInfoProvider, useGuidedTour, useNotification, } from '@strapi/helper-plugin'; @@ -27,7 +27,6 @@ const strapiVersion = packageJSON.version; const AuthenticatedApp = () => { const { setGuidedTourVisibility } = useGuidedTour(); const toggleNotification = useNotification(); - const setGuidedTourVisibilityRef = useRef(setGuidedTourVisibility); const userInfo = auth.getUserInfo(); const userName = get(userInfo, 'username') || getFullName(userInfo.firstname, userInfo.lastname); const [userDisplayName, setUserDisplayName] = useState(userName); @@ -35,7 +34,7 @@ const AuthenticatedApp = () => { const { showReleaseNotification } = useConfigurations(); const [ { data: appInfos, status }, - { data: tag_name, isLoading }, + { data: tagName, isLoading }, { data: permissions, status: fetchPermissionsStatus, refetch, isFetched, isFetching }, { data: userRoles }, ] = useQueries([ @@ -57,20 +56,20 @@ const AuthenticatedApp = () => { }, ]); - const shouldUpdateStrapi = useMemo( - () => checkLatestStrapiVersion(strapiVersion, tag_name), - [tag_name] - ); + const shouldUpdateStrapi = checkLatestStrapiVersion(strapiVersion, tagName); + /** + * TODO: does this actually need to be an effect? + */ useEffect(() => { if (userRoles) { const isUserSuperAdmin = userRoles.find(({ code }) => code === 'strapi-super-admin'); if (isUserSuperAdmin && appInfos?.autoReload) { - setGuidedTourVisibilityRef.current(true); + setGuidedTourVisibility(true); } } - }, [userRoles, appInfos]); + }, [userRoles, appInfos, setGuidedTourVisibility]); useEffect(() => { const getUserId = async () => { @@ -88,32 +87,28 @@ const AuthenticatedApp = () => { const shouldShowLoader = isLoading || shouldShowNotDependentQueriesLoader; - const appInfosValue = useMemo(() => { - return { - ...appInfos, - userId, - latestStrapiReleaseTag: tag_name, - setUserDisplayName, - shouldUpdateStrapi, - userDisplayName, - }; - }, [appInfos, tag_name, shouldUpdateStrapi, userDisplayName, userId]); - if (shouldShowLoader) { return ; } - // TODO add error state + // TODO: add error state if (status === 'error') { return
error...
; } return ( - + - + ); }; diff --git a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Blocker.js b/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Blocker.js deleted file mode 100644 index 3ebcdefa0e..0000000000 --- a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Blocker.js +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; -import styled, { keyframes } from 'styled-components'; -import { pxToRem } from '@strapi/helper-plugin'; -import { Clock, Refresh } from '@strapi/icons'; -import { Link } from '@strapi/design-system/v2'; -import { Box, Flex, Typography } from '@strapi/design-system'; -import { Content, IconBox, Overlay } from './Overlay'; - -const overlayContainer = document.createElement('div'); -const ID = 'autoReloadOverlayBlocker'; -overlayContainer.setAttribute('id', ID); - -const rotation = keyframes` - from { - transform: rotate(0deg); - } - to { - transform: rotate(359deg); - } -`; - -const LoaderReload = styled(Refresh)` - animation: ${rotation} 1s infinite linear; -`; - -const Blocker = ({ displayedIcon, description, title, isOpen }) => { - const { formatMessage } = useIntl(); - - useEffect(() => { - document.body.appendChild(overlayContainer); - - return () => { - document.body.removeChild(overlayContainer); - }; - }, []); - - if (isOpen) { - return ReactDOM.createPortal( - - - - - - {formatMessage(title)} - - - - - {formatMessage(description)} - - - - - {displayedIcon === 'reload' && ( - - - - )} - - {displayedIcon === 'time' && ( - - - - )} - - - - - {formatMessage({ - id: 'global.documentation', - defaultMessage: 'Read the documentation', - })} - - - - - , - overlayContainer - ); - } - - return null; -}; - -Blocker.propTypes = { - displayedIcon: PropTypes.oneOfType([PropTypes.string, PropTypes.array]).isRequired, - description: PropTypes.object.isRequired, - isOpen: PropTypes.bool.isRequired, - title: PropTypes.object.isRequired, -}; - -export default Blocker; diff --git a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Overlay.js b/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Overlay.js deleted file mode 100644 index 36454a9fc8..0000000000 --- a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/Overlay.js +++ /dev/null @@ -1,42 +0,0 @@ -import styled from 'styled-components'; -import { Box, Flex } from '@strapi/design-system'; -import { pxToRem } from '@strapi/helper-plugin'; - -const Overlay = styled(Box)` - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 1140; - &:before { - content: ''; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - background: ${({ theme }) => theme.colors.neutral0}; - opacity: 0.9; - } -`; - -const Content = styled(Flex)` - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - padding-top: ${pxToRem(160)}; -`; - -const IconBox = styled(Box)` - border-radius: 50%; - svg { - > path { - fill: ${({ theme }) => theme.colors.primary600} !important; - } - } -`; - -export { Content, IconBox, Overlay }; diff --git a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/index.js b/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/index.js deleted file mode 100644 index 089f949d37..0000000000 --- a/packages/core/admin/admin/src/components/AutoReloadOverlayBlockerProvider/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import { AutoReloadOverlayBockerContext } from '@strapi/helper-plugin'; -import Blocker from './Blocker'; - -const ELAPSED = 30; - -const AutoReloadOverlayBlockerProvider = ({ children }) => { - const [isOpen, setIsOpen] = useState(false); - const [{ elapsed }, setState] = useState({ elapsed: 0, start: 0 }); - const [config, setConfig] = useState(undefined); - - const lockAppWithAutoreload = (config = undefined) => { - setIsOpen(true); - setConfig(config); - setState((prev) => ({ ...prev, start: Date.now() })); - }; - - const unlockAppWithAutoreload = () => { - setIsOpen(false); - setState({ start: 0, elapsed: 0 }); - setConfig(undefined); - }; - - const lockApp = useRef(lockAppWithAutoreload); - const unlockApp = useRef(unlockAppWithAutoreload); - - useEffect(() => { - let timer = null; - - if (isOpen) { - timer = setInterval(() => { - if (elapsed > ELAPSED) { - clearInterval(timer); - - return null; - } - - setState((prev) => ({ ...prev, elapsed: Math.round(Date.now() - prev.start) / 1000 })); - - return null; - }, 1000); - } else { - clearInterval(timer); - } - - return () => { - clearInterval(timer); - }; - }, [isOpen, elapsed]); - - let displayedIcon = config?.icon || 'reload'; - - let description = { - id: config?.description || 'components.OverlayBlocker.description', - defaultMessage: - "You're using a feature that needs the server to restart. Please wait until the server is up.", - }; - let title = { - id: config?.title || 'components.OverlayBlocker.title', - defaultMessage: 'Waiting for restart', - }; - - if (elapsed > ELAPSED) { - displayedIcon = 'time'; - - description = { - id: 'components.OverlayBlocker.description.serverError', - defaultMessage: 'The server should have restarted, please check your logs in the terminal.', - }; - - title = { - id: 'components.OverlayBlocker.title.serverError', - defaultMessage: 'The restart is taking longer than expected', - }; - } - - const autoReloadValue = useMemo(() => { - return { lockApp: lockApp.current, unlockApp: unlockApp.current }; - }, [lockApp, unlockApp]); - - return ( - - - {children} - - ); -}; - -AutoReloadOverlayBlockerProvider.propTypes = { - children: PropTypes.element.isRequired, -}; - -export default AutoReloadOverlayBlockerProvider; diff --git a/packages/core/admin/admin/src/components/DragLayer/DragLayer.js b/packages/core/admin/admin/src/components/DragLayer/DragLayer.js new file mode 100644 index 0000000000..6a4e1ea60e --- /dev/null +++ b/packages/core/admin/admin/src/components/DragLayer/DragLayer.js @@ -0,0 +1,53 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useDragLayer } from 'react-dnd'; +import { Box } from '@strapi/design-system'; + +function getStyle(initialOffset, currentOffset, mouseOffset) { + if (!initialOffset || !currentOffset) { + return { display: 'none' }; + } + + const { x, y } = mouseOffset; + + return { + transform: `translate(${x}px, ${y}px)`, + }; +} + +export function DragLayer({ renderItem }) { + const { itemType, isDragging, item, initialOffset, currentOffset, mouseOffset } = useDragLayer( + (monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + initialOffset: monitor.getInitialSourceClientOffset(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + mouseOffset: monitor.getClientOffset(), + }) + ); + + if (!isDragging) { + return null; + } + + return ( + + + {renderItem({ type: itemType, item })} + + + ); +} + +DragLayer.propTypes = { + renderItem: PropTypes.func.isRequired, +}; diff --git a/packages/core/admin/admin/src/components/DragLayer/index.js b/packages/core/admin/admin/src/components/DragLayer/index.js new file mode 100644 index 0000000000..c3e27fec39 --- /dev/null +++ b/packages/core/admin/admin/src/components/DragLayer/index.js @@ -0,0 +1 @@ +export * from './DragLayer'; diff --git a/packages/core/admin/admin/src/components/LeftMenu/index.js b/packages/core/admin/admin/src/components/LeftMenu/index.js index d466ad4c24..daf5466c89 100644 --- a/packages/core/admin/admin/src/components/LeftMenu/index.js +++ b/packages/core/admin/admin/src/components/LeftMenu/index.js @@ -18,7 +18,7 @@ import { Write, Exit } from '@strapi/icons'; import { auth, usePersistentState, - useAppInfos, + useAppInfo, useTracking, getFetchClient, } from '@strapi/helper-plugin'; @@ -59,7 +59,7 @@ const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }) => { logos: { menu }, } = useConfigurations(); const [condensed, setCondensed] = usePersistentState('navbar-condensed', false); - const { userDisplayName } = useAppInfos(); + const { userDisplayName } = useAppInfo(); const { formatMessage } = useIntl(); const { trackUsage } = useTracking(); const { pathname } = useLocation(); diff --git a/packages/core/admin/admin/src/components/Notifications/Notification/index.js b/packages/core/admin/admin/src/components/Notifications/Notification/index.js deleted file mode 100644 index 02e1291527..0000000000 --- a/packages/core/admin/admin/src/components/Notifications/Notification/index.js +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useEffect, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; -import { Alert } from '@strapi/design-system'; -import { Link } from '@strapi/design-system/v2'; - -const Notification = ({ dispatch, notification }) => { - const { formatMessage } = useIntl(); - const { message, link, type, id, onClose, timeout, blockTransition, title } = notification; - - const formattedMessage = (msg) => - typeof msg === 'string' ? msg : formatMessage(msg, msg.values); - const handleClose = useCallback(() => { - if (onClose) { - onClose(); - } - - dispatch({ - type: 'HIDE_NOTIFICATION', - id, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - useEffect(() => { - let timeoutToClear; - - if (!blockTransition) { - timeoutToClear = setTimeout(() => { - handleClose(); - }, timeout || 2500); - } - - return () => clearTimeout(timeoutToClear); - }, [blockTransition, handleClose, timeout]); - - let variant; - let alertTitle; - - // TODO break out this logic into separate file - if (type === 'info') { - variant = 'default'; - alertTitle = formatMessage({ - id: 'notification.default.title', - defaultMessage: 'Information:', - }); - } else if (type === 'warning') { - // type should be renamed to danger in the future, but it might introduce changes if done now - variant = 'danger'; - alertTitle = formatMessage({ - id: 'notification.warning.title', - defaultMessage: 'Warning:', - }); - } else if (type === 'softWarning') { - // type should be renamed to just warning in the future - variant = 'warning'; - alertTitle = formatMessage({ - id: 'notification.warning.title', - defaultMessage: 'Warning:', - }); - } else { - variant = 'success'; - alertTitle = formatMessage({ - id: 'notification.success.title', - defaultMessage: 'Success:', - }); - } - - if (title) { - alertTitle = - typeof title === 'string' - ? title - : formattedMessage({ - id: title?.id || title, - defaultMessage: title?.defaultMessage || title?.id || title, - values: title?.values, - }); - } - - return ( - - {formatMessage({ - id: link.label?.id || link.label, - defaultMessage: link.label?.defaultMessage || link.label?.id || link.label, - })} - - ) : undefined - } - onClose={handleClose} - closeLabel="Close" - title={alertTitle} - variant={variant} - > - {formattedMessage({ - id: message?.id || message, - defaultMessage: message?.defaultMessage || message?.id || message, - values: message?.values, - })} - - ); -}; - -Notification.defaultProps = { - notification: { - id: 1, - type: 'success', - message: { - id: 'notification.success.saved', - defaultMessage: 'Saved', - }, - onClose: () => null, - timeout: 2500, - blockTransition: false, - }, -}; - -Notification.propTypes = { - dispatch: PropTypes.func.isRequired, - notification: PropTypes.shape({ - id: PropTypes.number, - message: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string, - values: PropTypes.object, - }), - ]), - link: PropTypes.shape({ - target: PropTypes.string, - url: PropTypes.string.isRequired, - label: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string, - values: PropTypes.object, - }), - ]).isRequired, - }), - type: PropTypes.string, - onClose: PropTypes.func, - timeout: PropTypes.number, - blockTransition: PropTypes.bool, - title: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string, - values: PropTypes.object, - }), - ]), - }), -}; - -export default Notification; diff --git a/packages/core/admin/admin/src/components/Notifications/index.js b/packages/core/admin/admin/src/components/Notifications/index.js deleted file mode 100644 index 58cd832610..0000000000 --- a/packages/core/admin/admin/src/components/Notifications/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import { NotificationsProvider } from '@strapi/helper-plugin'; -import React, { useReducer } from 'react'; -import PropTypes from 'prop-types'; -import { Flex } from '@strapi/design-system'; -import Notification from './Notification'; -import reducer, { initialState } from './reducer'; - -const Notifications = ({ children }) => { - const [{ notifications }, dispatch] = useReducer(reducer, initialState); - - const displayNotification = (config) => { - dispatch({ - type: 'SHOW_NOTIFICATION', - config, - }); - }; - - return ( - - - {notifications.map((notification) => { - return ( - - ); - })} - - {children} - - ); -}; - -Notifications.propTypes = { - children: PropTypes.element.isRequired, -}; - -export default Notifications; diff --git a/packages/core/admin/admin/src/components/Notifications/reducer.js b/packages/core/admin/admin/src/components/Notifications/reducer.js deleted file mode 100644 index 318aaf0ea3..0000000000 --- a/packages/core/admin/admin/src/components/Notifications/reducer.js +++ /dev/null @@ -1,47 +0,0 @@ -import produce from 'immer'; -import get from 'lodash/get'; - -const initialState = { - notifId: 0, - notifications: [], -}; - -const notificationReducer = (state = initialState, action) => - // eslint-disable-next-line consistent-return - produce(state, (draftState) => { - switch (action.type) { - case 'SHOW_NOTIFICATION': { - draftState.notifications.push({ - // No action.config spread to limit the notification API and avoid customization - id: state.notifId, - type: get(action, ['config', 'type'], 'success'), - message: get(action, ['config', 'message'], { - id: 'notification.success.saved', - defaultMessage: 'Saved', - }), - link: get(action, ['config', 'link'], null), - timeout: get(action, ['config', 'timeout'], 2500), - blockTransition: get(action, ['config', 'blockTransition'], false), - onClose: get(action, ['config', 'onClose'], null), - title: get(action, ['config', 'title'], null), - }); - draftState.notifId = state.notifId + 1; - break; - } - case 'HIDE_NOTIFICATION': { - const indexToRemove = state.notifications.findIndex((notif) => notif.id === action.id); - - if (indexToRemove !== -1) { - draftState.notifications.splice(indexToRemove, 1); - } - break; - } - - default: { - return draftState; - } - } - }); - -export default notificationReducer; -export { initialState }; diff --git a/packages/core/admin/admin/src/components/Notifications/tests/index.test.js b/packages/core/admin/admin/src/components/Notifications/tests/index.test.js deleted file mode 100644 index 949ef53c5b..0000000000 --- a/packages/core/admin/admin/src/components/Notifications/tests/index.test.js +++ /dev/null @@ -1,161 +0,0 @@ -/** - * - * Tests for Notifications - * - */ - -import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; -import { IntlProvider } from 'react-intl'; -import { useNotification } from '@strapi/helper-plugin'; -import { act } from 'react-dom/test-utils'; -import { lightTheme, darkTheme } from '@strapi/design-system'; -import Theme from '../../Theme'; -import ThemeToggleProvider from '../../ThemeToggleProvider'; -import Notifications from '../index'; - -const messages = {}; - -describe('', () => { - it('renders and matches the snapshot', () => { - const { - container: { firstChild }, - } = render( - - - - -
- - - - - ); - - expect(firstChild).toMatchInlineSnapshot(` - .c0 { - margin-left: -250px; - position: fixed; - left: 50%; - top: 2.875rem; - z-index: 10; - width: 31.25rem; - } - - .c1 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - gap: 8px; - } - -
- `); - }); - - it('should display a notification correctly', async () => { - const Button = () => { - const toggleNotification = useNotification(); - - const handleClick = () => { - toggleNotification({ type: 'success', message: 'simple notif' }); - }; - - return ( - - ); - }; - - render( - - - - - - ); - }; - - render( - - - - - + } + title={formatMessage({ + id: 'Settings.review-workflows.page.title', + defaultMessage: 'Review Workflows', + })} + subtitle={formatMessage( + { + id: 'Settings.review-workflows.page.subtitle', + defaultMessage: '{count, plural, one {# stage} other {# stages}}', + }, + { count: currentWorkflow?.stages?.length ?? 0 } + )} + /> + + {status === 'loading' && ( + + {formatMessage({ + id: 'Settings.review-workflows.page.isLoading', + defaultMessage: 'Workflow is loading', + })} + + )} + + + + + + + + + + + ); +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js new file mode 100644 index 0000000000..1df28ee9f1 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/index.js @@ -0,0 +1,42 @@ +import { + ACTION_SET_WORKFLOWS, + ACTION_DELETE_STAGE, + ACTION_ADD_STAGE, + ACTION_UPDATE_STAGE, +} from '../constants'; + +export function setWorkflows({ status, data }) { + return { + type: ACTION_SET_WORKFLOWS, + payload: { + status, + workflows: data, + }, + }; +} + +export function deleteStage(stageId) { + return { + type: ACTION_DELETE_STAGE, + payload: { + stageId, + }, + }; +} + +export function addStage(stage = {}) { + return { + type: ACTION_ADD_STAGE, + payload: stage, + }; +} + +export function updateStage(stageId, payload) { + return { + type: ACTION_UPDATE_STAGE, + payload: { + stageId, + ...payload, + }, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js new file mode 100644 index 0000000000..0edaf00262 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/actions/tests/index.test.js @@ -0,0 +1,53 @@ +import { setWorkflows, deleteStage, updateStage, addStage } from '..'; + +import { + ACTION_SET_WORKFLOWS, + ACTION_DELETE_STAGE, + ACTION_ADD_STAGE, + ACTION_UPDATE_STAGE, +} from '../../constants'; + +describe('Admin | Settings | Review Workflow | actions', () => { + test('setWorkflows()', () => { + expect(setWorkflows({ status: 'loading', data: null, something: 'else' })).toStrictEqual({ + type: ACTION_SET_WORKFLOWS, + payload: { + status: 'loading', + workflows: null, + }, + }); + }); + + test('deleteStage()', () => { + expect(deleteStage(1)).toStrictEqual({ + type: ACTION_DELETE_STAGE, + payload: { + stageId: 1, + }, + }); + }); + + test('addStage()', () => { + expect(addStage({ something: '' })).toStrictEqual({ + type: ACTION_ADD_STAGE, + payload: { + something: '', + }, + }); + + expect(addStage()).toStrictEqual({ + type: ACTION_ADD_STAGE, + payload: {}, + }); + }); + + test('updateStage()', () => { + expect(updateStage(1, { something: '' })).toStrictEqual({ + type: ACTION_UPDATE_STAGE, + payload: { + stageId: 1, + something: '', + }, + }); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/AddStage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/AddStage.js new file mode 100644 index 0000000000..84f4883009 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/AddStage.js @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; + +import { Box, Flex, Typography } from '@strapi/design-system'; +import { PlusCircle } from '@strapi/icons'; + +const StyledAddIcon = styled(PlusCircle)` + > circle { + fill: ${({ theme }) => theme.colors.neutral150}; + } + > path { + fill: ${({ theme }) => theme.colors.neutral600}; + } +`; + +const StyledButton = styled(Box)` + border-radius: 26px; + + svg { + height: ${({ theme }) => theme.spaces[6]}; + width: ${({ theme }) => theme.spaces[6]}; + + > path { + fill: ${({ theme }) => theme.colors.neutral600}; + } + } + + &:hover { + color: ${({ theme }) => theme.colors.primary600} !important; + ${Typography} { + color: ${({ theme }) => theme.colors.primary600} !important; + } + + ${StyledAddIcon} { + > circle { + fill: ${({ theme }) => theme.colors.primary600}; + } + > path { + fill: ${({ theme }) => theme.colors.neutral100}; + } + } + } + + &:active { + ${Typography} { + color: ${({ theme }) => theme.colors.primary600}; + } + + ${StyledAddIcon} { + > circle { + fill: ${({ theme }) => theme.colors.primary600}; + } + > path { + fill: ${({ theme }) => theme.colors.neutral100}; + } + } + } +`; + +export function AddStage({ children, ...props }) { + return ( + + + + + + {children} + + + + ); +} + +AddStage.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/index.js new file mode 100644 index 0000000000..1fb8b2ecd2 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/index.js @@ -0,0 +1 @@ +export * from './AddStage'; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/AddStage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/AddStage.test.js new file mode 100644 index 0000000000..98d08af1cd --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/AddStage.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ThemeProvider, lightTheme } from '@strapi/design-system'; + +import { AddStage } from '../AddStage'; + +const ComponentFixture = () => ( + + Add stage + +); + +const setup = (props) => render(); + +describe('Admin | Settings | Review Workflow | AddStage', () => { + it('should render a list of stages', () => { + const { container, getByText } = setup(); + + expect(container).toMatchSnapshot(); + expect(getByText('Add stage')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/__snapshots__/AddStage.test.js.snap b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/__snapshots__/AddStage.test.js.snap new file mode 100644 index 0000000000..9159ee60e4 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/AddStage/tests/__snapshots__/AddStage.test.js.snap @@ -0,0 +1,153 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Admin | Settings | Review Workflow | AddStage should render a list of stages 1`] = ` +.c0 { + background: #ffffff; + padding-top: 12px; + padding-right: 16px; + padding-bottom: 12px; + padding-left: 16px; + box-shadow: 0px 1px 4px rgba(33,33,52,0.1); +} + +.c2 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: 8px; +} + +.c6 { + font-size: 0.75rem; + line-height: 1.33; + font-weight: 600; + color: #8e8ea9; +} + +.c7 { + border: 0; + -webkit-clip: rect(0 0 0 0); + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.c4 > circle { + fill: #eaeaef; +} + +.c4 > path { + fill: #666687; +} + +.c1 { + border-radius: 26px; +} + +.c1 svg { + height: 24px; + width: 24px; +} + +.c1 svg > path { + fill: #666687; +} + +.c1:hover { + color: #4945ff !important; +} + +.c1:hover .c5 { + color: #4945ff !important; +} + +.c1:hover .c3 > circle { + fill: #4945ff; +} + +.c1:hover .c3 > path { + fill: #f6f6f9; +} + +.c1:active .c5 { + color: #4945ff; +} + +.c1:active .c3 > circle { + fill: #4945ff; +} + +.c1:active .c3 > path { + fill: #f6f6f9; +} + +
+ +
+

+

+

+
+`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js new file mode 100644 index 0000000000..1b8900e739 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/Stage.js @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useField } from 'formik'; +import { useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { + Accordion, + AccordionToggle, + AccordionContent, + Grid, + GridItem, + IconButton, + TextInput, +} from '@strapi/design-system'; +import { useTracking } from '@strapi/helper-plugin'; +import { Trash } from '@strapi/icons'; + +import { deleteStage, updateStage } from '../../../actions'; + +function Stage({ id, name, index, canDelete, isOpen: isOpenDefault = false }) { + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + const [isOpen, setIsOpen] = useState(isOpenDefault); + const fieldIdentifier = `stages.${index}.name`; + const [field, meta] = useField(fieldIdentifier); + const dispatch = useDispatch(); + + return ( + { + setIsOpen(!isOpen); + + if (!isOpen) { + trackUsage('willEditStage'); + } + }} + expanded={isOpen} + shadow="tableShadow" + > + dispatch(deleteStage(id))} + label={formatMessage({ + id: 'Settings.review-workflows.stage.delete', + defaultMessage: 'Delete stage', + })} + icon={} + /> + ) : null + } + /> + + + + { + field.onChange(event); + dispatch(updateStage(id, { name: event.target.value })); + }} + /> + + + + + ); +} + +export { Stage }; + +Stage.propTypes = PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + canDelete: PropTypes.bool.isRequired, +}).isRequired; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/index.js new file mode 100644 index 0000000000..1dd571965a --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/index.js @@ -0,0 +1 @@ +export * from './Stage'; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js new file mode 100644 index 0000000000..3a216c561d --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stage/tests/Stage.test.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { FormikProvider, useFormik } from 'formik'; +import { Provider } from 'react-redux'; + +import { ThemeProvider, lightTheme } from '@strapi/design-system'; + +import configureStore from '../../../../../../../../../../admin/src/core/store/configureStore'; +import { Stage } from '../Stage'; +import { reducer } from '../../../../reducer'; + +jest.mock('@strapi/helper-plugin', () => ({ + ...jest.requireActual('@strapi/helper-plugin'), + useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }), +})); + +const STAGES_FIXTURE = { + id: 1, + name: 'stage-1', + index: 1, +}; + +const ComponentFixture = (props) => { + const store = configureStore([], [reducer]); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + stages: [ + { + name: 'something', + }, + ], + }, + validateOnChange: false, + }); + + return ( + + + + + + + + + + ); +}; + +const setup = (props) => render(); + +const user = userEvent.setup(); + +describe('Admin | Settings | Review Workflow | Stage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render a stage', async () => { + const { getByRole, queryByRole } = setup(); + + expect(queryByRole('textbox')).not.toBeInTheDocument(); + + await user.click(getByRole('button')); + + expect(queryByRole('textbox')).toBeInTheDocument(); + expect(getByRole('textbox').value).toBe(STAGES_FIXTURE.name); + expect(getByRole('textbox').getAttribute('name')).toBe('stages.1.name'); + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument(); + }); + + it('should open the accordion panel if isOpen = true', async () => { + const { queryByRole } = setup({ isOpen: true }); + + expect(queryByRole('textbox')).toBeInTheDocument(); + }); + + it('should not render delete button if canDelete=false', async () => { + const { queryByRole } = setup({ isOpen: true, canDelete: false }); + + expect( + queryByRole('button', { + name: /delete stage/i, + }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js new file mode 100644 index 0000000000..9a4060614b --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/Stages.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { Box, Flex } from '@strapi/design-system'; +import { useTracking } from '@strapi/helper-plugin'; + +import { addStage } from '../../actions'; +import { AddStage } from '../AddStage'; +import { Stage } from './Stage'; + +const StagesContainer = styled(Box)` + position: relative; +`; + +const Background = styled(Box)` + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); +`; + +function Stages({ stages }) { + const { formatMessage } = useIntl(); + const dispatch = useDispatch(); + const { trackUsage } = useTracking(); + + return ( + + + + + + {stages.map((stage, index) => { + const id = stage?.id ?? stage.__temp_key__; + + return ( + + 1} + isOpen={!stage.id} + /> + + ); + })} + + + + + { + dispatch(addStage({ name: '' })); + trackUsage('willCreateStage'); + }} + > + {formatMessage({ + id: 'Settings.review-workflows.stage.add', + defaultMessage: 'Add new stage', + })} + + + + ); +} + +export { Stages }; + +Stages.defaultProps = { + stages: [], +}; + +Stages.propTypes = { + stages: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + __temp_key__: PropTypes.number, + name: PropTypes.string.isRequired, + }) + ), +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/index.js new file mode 100644 index 0000000000..a0c3b6753c --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/index.js @@ -0,0 +1 @@ +export * from './Stages'; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js new file mode 100644 index 0000000000..63ce038e77 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/components/Stages/tests/Stages.test.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; +import { FormikProvider, useFormik } from 'formik'; +import userEvent from '@testing-library/user-event'; + +import { ThemeProvider, lightTheme } from '@strapi/design-system'; + +import configureStore from '../../../../../../../../../admin/src/core/store/configureStore'; +import { Stages } from '../Stages'; +import { reducer } from '../../../reducer'; +import { ACTION_SET_WORKFLOWS } from '../../../constants'; +import * as actions from '../../../actions'; + +// without mocking actions as ESM it is impossible to spy on named exports +jest.mock('../../../actions', () => ({ + __esModule: true, + ...jest.requireActual('../../../actions'), +})); + +jest.mock('@strapi/helper-plugin', () => ({ + ...jest.requireActual('@strapi/helper-plugin'), + useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }), +})); + +const STAGES_FIXTURE = [ + { + id: 1, + name: 'stage-1', + }, + + { + id: 2, + name: 'stage-2', + }, +]; + +const WORKFLOWS_FIXTURE = [ + { + id: 1, + stages: STAGES_FIXTURE, + }, +]; + +const ComponentFixture = (props) => { + const store = configureStore([], [reducer]); + + store.dispatch({ type: ACTION_SET_WORKFLOWS, payload: { workflows: WORKFLOWS_FIXTURE } }); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + stages: STAGES_FIXTURE, + }, + validateOnChange: false, + }); + + return ( + + + + + + + + + + ); +}; + +const setup = (props) => render(); + +const user = userEvent.setup(); + +describe('Admin | Settings | Review Workflow | Stages', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render a list of stages', () => { + const { getByText } = setup(); + + expect(getByText(STAGES_FIXTURE[0].name)).toBeInTheDocument(); + expect(getByText(STAGES_FIXTURE[1].name)).toBeInTheDocument(); + }); + + it('should render a "add new stage" button', () => { + const { getByText } = setup(); + + expect(getByText('Add new stage')).toBeInTheDocument(); + }); + + it('should append a new stage when clicking "add new stage"', async () => { + const { getByRole } = setup(); + const spy = jest.spyOn(actions, 'addStage'); + + await user.click( + getByRole('button', { + name: /add new stage/i, + }) + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith({ name: '' }); + }); + + it('should update the name of a stage by changing the input value', async () => { + const { queryByRole, getByRole } = setup(); + const spy = jest.spyOn(actions, 'updateStage'); + + await user.click(getByRole('button', { name: /stage-2/i })); + + const input = queryByRole('textbox', { + name: /stage name/i, + }); + + fireEvent.change(input, { target: { value: 'New name' } }); + + expect(spy).toBeCalledWith(2, { + name: 'New name', + }); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js new file mode 100644 index 0000000000..eac72aeb29 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/constants.js @@ -0,0 +1,6 @@ +export const REDUX_NAMESPACE = 'settings_review-workflows'; + +export const ACTION_SET_WORKFLOWS = `Settings/Review_Workflows/SET_WORKFLOWS`; +export const ACTION_DELETE_STAGE = `Settings/Review_Workflows/WORKFLOW_DELETE_STAGE`; +export const ACTION_ADD_STAGE = `Settings/Review_Workflows/WORKFLOW_ADD_STAGE`; +export const ACTION_UPDATE_STAGE = `Settings/Review_Workflows/WORKFLOW_UPDATE_STAGE`; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/tests/useReviewWorkflows.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/tests/useReviewWorkflows.test.js new file mode 100644 index 0000000000..2d4124c83e --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/tests/useReviewWorkflows.test.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { QueryClientProvider, QueryClient } from 'react-query'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { IntlProvider } from 'react-intl'; + +import { useFetchClient } from '@strapi/helper-plugin'; + +import { useReviewWorkflows } from '../useReviewWorkflows'; + +jest.mock('@strapi/helper-plugin', () => ({ + ...jest.requireActual('@strapi/helper-plugin'), + useFetchClient: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: {} }), + }), +})); + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// eslint-disable-next-line react/prop-types +const ComponentFixture = ({ children }) => ( + + {children} + +); + +function setup(id) { + return new Promise((resolve) => { + act(() => { + resolve(renderHook(() => useReviewWorkflows(id), { wrapper: ComponentFixture })); + }); + }); +} + +describe('useReviewWorkflows', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('fetch all workflows when calling the hook without a workflow id', async () => { + const { get } = useFetchClient(); + + get.mockResolvedValue({ + data: { + data: [ + { + id: 1, + stages: [], + }, + + { + id: 2, + stages: [], + }, + ], + }, + }); + + const { result, waitFor } = await setup(); + + expect(result.current.workflows.isLoading).toBe(true); + expect(get).toBeCalledWith('/admin/review-workflows/workflows/', { + params: { populate: 'stages' }, + }); + + await waitFor(() => expect(result.current.workflows.isLoading).toBe(false)); + + expect(result.current).toStrictEqual( + expect.objectContaining({ + workflows: expect.objectContaining({ + data: expect.arrayContaining([{ id: expect.any(Number), stages: expect.any(Array) }]), + }), + }) + ); + }); + + test('fetch a single workflow when calling the hook with a workflow id', async () => { + const { get } = useFetchClient(); + const idFixture = 1; + + get.mockResolvedValue({ + data: { + data: { + id: idFixture, + stages: [], + }, + }, + }); + + const { result, waitFor } = await setup(idFixture); + + expect(result.current.workflows.isLoading).toBe(true); + expect(get).toBeCalledWith( + `/admin/review-workflows/workflows/${idFixture}`, + expect.any(Object) + ); + + await waitFor(() => expect(result.current.workflows.isLoading).toBe(false)); + + expect(result.current).toStrictEqual( + expect.objectContaining({ + workflows: expect.objectContaining({ + data: expect.objectContaining({ id: expect.any(Number), stages: expect.any(Array) }), + }), + }) + ); + }); + + test('refetchWorkflow() re-fetches the loaded default workflow', async () => { + const { result } = await setup(); + + const spy = jest.spyOn(client, 'refetchQueries'); + + await act(async () => { + result.current.refetchWorkflow(); + }); + + expect(spy).toBeCalledWith(['review-workflows', 'default']); + }); + + test('refetchWorkflow() re-fetches the loaded workflow id', async () => { + const { result } = await setup(1); + + const spy = jest.spyOn(client, 'refetchQueries'); + + await act(async () => { + result.current.refetchWorkflow(); + }); + + expect(spy).toBeCalledWith(['review-workflows', 1]); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js new file mode 100644 index 0000000000..6a2a15ec38 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows.js @@ -0,0 +1,35 @@ +import { useQuery, useQueryClient } from 'react-query'; +import { useFetchClient } from '@strapi/helper-plugin'; + +const QUERY_BASE_KEY = 'review-workflows'; +const API_BASE_URL = '/admin/review-workflows'; + +export function useReviewWorkflows(workflowId) { + const { get } = useFetchClient(); + const client = useQueryClient(); + const workflowQueryKey = [QUERY_BASE_KEY, workflowId ?? 'default']; + + async function fetchWorkflows({ params = { populate: 'stages' } }) { + try { + const { + data: { data }, + } = await get(`${API_BASE_URL}/workflows/${workflowId ?? ''}`, { params }); + + return data; + } catch (err) { + // silence + return null; + } + } + + async function refetchWorkflow() { + await client.refetchQueries(workflowQueryKey); + } + + const workflows = useQuery(workflowQueryKey, fetchWorkflows); + + return { + workflows, + refetchWorkflow, + }; +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/index.js new file mode 100644 index 0000000000..0ae110cd50 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/index.js @@ -0,0 +1,3 @@ +import { ReviewWorkflowsPage } from './ReviewWorkflows'; + +export default ReviewWorkflowsPage; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js new file mode 100644 index 0000000000..dedec82f3e --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/index.js @@ -0,0 +1,122 @@ +import { current, produce } from 'immer'; +import isEqual from 'lodash/isEqual'; + +import { + ACTION_SET_WORKFLOWS, + ACTION_DELETE_STAGE, + ACTION_ADD_STAGE, + ACTION_UPDATE_STAGE, +} from '../constants'; + +export const initialState = { + status: 'loading', + serverState: { + currentWorkflow: null, + workflows: [], + }, + clientState: { + currentWorkflow: { data: null, isDirty: false, hasDeletedServerStages: false }, + }, +}; + +export function reducer(state = initialState, action) { + return produce(state, (draft) => { + const { payload } = action; + + switch (action.type) { + case ACTION_SET_WORKFLOWS: { + const { status, workflows } = payload; + + draft.status = status; + + if (workflows) { + const defaultWorkflow = workflows[0]; + + draft.serverState.workflows = workflows; + draft.serverState.currentWorkflow = defaultWorkflow; + draft.clientState.currentWorkflow.data = defaultWorkflow; + draft.clientState.currentWorkflow.hasDeletedServerStages = false; + } + break; + } + + case ACTION_DELETE_STAGE: { + const { stageId } = payload; + const { currentWorkflow } = state.clientState; + + draft.clientState.currentWorkflow.data.stages = currentWorkflow.data.stages.filter( + (stage) => (stage?.id ?? stage.__temp_key__) !== stageId + ); + + if (!currentWorkflow.hasDeletedServerStages) { + draft.clientState.currentWorkflow.hasDeletedServerStages = + !!state.serverState.currentWorkflow.stages.find((stage) => stage.id === stageId); + } + + break; + } + + case ACTION_ADD_STAGE: { + const { currentWorkflow } = state.clientState; + + if (!currentWorkflow.data) { + draft.clientState.currentWorkflow.data = { + stages: [], + }; + } + + const newTempKey = getMaxTempKey(draft.clientState.currentWorkflow.data.stages); + + draft.clientState.currentWorkflow.data.stages.push({ + ...payload, + __temp_key__: newTempKey, + }); + + break; + } + + case ACTION_UPDATE_STAGE: { + const { currentWorkflow } = state.clientState; + const { stageId, ...modified } = payload; + + draft.clientState.currentWorkflow.data.stages = currentWorkflow.data.stages.map((stage) => + (stage.id ?? stage.__temp_key__) === stageId + ? { + ...stage, + ...modified, + } + : stage + ); + + break; + } + + default: + break; + } + + if (state.clientState.currentWorkflow.data) { + draft.clientState.currentWorkflow.isDirty = !isEqual( + current(draft.clientState.currentWorkflow).data, + draft.serverState.currentWorkflow + ); + } + }); +} + +/** + * @type {(stages: Array<{id?: number; __temp_key__: number}>) => number} + */ +const getMaxTempKey = (stages = []) => { + /** + * We check if there are ids or __temp_key__ because you may add a stage to a list of stages + * already in the DB, alternatively you might add multiple new stages at once. + */ + const ids = stages.map((stage) => stage.id ?? stage.__temp_key__); + + /** + * If there are no ids it will return 0 as the max value + * because the max value is -1. + */ + return Math.max(...ids, -1) + 1; +}; diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js new file mode 100644 index 0000000000..cef48abf9b --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/reducer/tests/index.test.js @@ -0,0 +1,396 @@ +import { initialState, reducer } from '..'; + +import { + ACTION_SET_WORKFLOWS, + ACTION_DELETE_STAGE, + ACTION_ADD_STAGE, + ACTION_UPDATE_STAGE, +} from '../../constants'; + +const WORKFLOWS_FIXTURE = [ + { + id: 1, + stages: [ + { + id: 1, + name: 'stage-1', + }, + + { + id: 2, + name: 'stage-2', + }, + ], + }, +]; + +describe('Admin | Settings | Review Workflows | reducer', () => { + let state; + + beforeEach(() => { + state = initialState; + }); + + test('should return the initialState', () => { + expect(reducer(state, {})).toStrictEqual(initialState); + }); + + test('ACTION_SET_WORKFLOWS with workflows', () => { + const action = { + type: ACTION_SET_WORKFLOWS, + payload: { status: 'loading-state', workflows: WORKFLOWS_FIXTURE }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + status: 'loading-state', + serverState: expect.objectContaining({ + currentWorkflow: WORKFLOWS_FIXTURE[0], + workflows: WORKFLOWS_FIXTURE, + }), + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + hasDeletedServerStages: false, + }), + }), + }) + ); + }); + + test('ACTION_SET_WORKFLOWS without workflows', () => { + const action = { + type: ACTION_SET_WORKFLOWS, + payload: { status: 'loading', workflows: null }, + }; + + expect(reducer(state, action)).toStrictEqual({ + ...initialState, + serverState: expect.objectContaining({ + currentWorkflow: null, + }), + }); + }); + + test('ACTION_DELETE_STAGE', () => { + const action = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 1 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { data: WORKFLOWS_FIXTURE[0], isDirty: false }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: expect.arrayContaining([WORKFLOWS_FIXTURE[0].stages[1]]), + }), + }), + }), + }) + ); + }); + + test('ACTION_DELETE_STAGE - set hasDeletedServerStages to true if stageId exists on the server', () => { + const action = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 1 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + hasDeletedServerStages: true, + }), + }), + }) + ); + }); + + test('ACTION_DELETE_STAGE - set hasDeletedServerStages to false if stageId does not exist on the server', () => { + const action = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 3 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: { + ...WORKFLOWS_FIXTURE[0], + stages: [...WORKFLOWS_FIXTURE[0].stages, { __temp_key__: 3, name: 'something' }], + }, + isDirty: false, + }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + hasDeletedServerStages: false, + }), + }), + }) + ); + }); + + test('ACTION_DELETE_STAGE - keep hasDeletedServerStages true as soon as one server stage has been deleted', () => { + const actionDeleteServerStage = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 1 }, + }; + + const actionDeleteClientStage = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 3 }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { + data: WORKFLOWS_FIXTURE[0], + isDirty: false, + }, + }, + }; + + state = reducer(state, actionDeleteServerStage); + state = reducer(state, actionDeleteClientStage); + + expect(state).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + hasDeletedServerStages: true, + }), + }), + }) + ); + }); + + test('ACTION_ADD_STAGE', () => { + const action = { + type: ACTION_ADD_STAGE, + payload: { name: 'something' }, + }; + + state = { + status: expect.any(String), + serverState: expect.any(Object), + clientState: { + currentWorkflow: { data: WORKFLOWS_FIXTURE[0], isDirty: false }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: expect.arrayContaining([ + { + __temp_key__: 3, + name: 'something', + }, + ]), + }), + }), + }), + }) + ); + }); + + test('ACTION_ADD_STAGE when there are not stages yet', () => { + const action = { + type: ACTION_ADD_STAGE, + payload: { name: 'something' }, + }; + + state = { + status: expect.any(String), + serverState: expect.any(Object), + clientState: { + currentWorkflow: { data: null, isDirty: false }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: expect.arrayContaining([ + { + __temp_key__: 0, + name: 'something', + }, + ]), + }), + }), + }), + }) + ); + }); + + test('ACTION_ADD_STAGE should correctly append the key when the ids are not sequential', () => { + const WORKFLOWS_FIXTURE = [ + { + id: 1, + stages: [ + { + id: 1, + name: 'stage-1', + }, + + { + id: 3, + name: 'stage-2', + }, + ], + }, + ]; + + const action = { + type: ACTION_ADD_STAGE, + payload: { name: 'something' }, + }; + + state = { + status: expect.any(String), + serverState: expect.any(Object), + clientState: { + currentWorkflow: { data: WORKFLOWS_FIXTURE[0], isDirty: false }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: expect.arrayContaining([ + { + __temp_key__: 4, + name: 'something', + }, + ]), + }), + }), + }), + }) + ); + }); + + test('ACTION_UPDATE_STAGE', () => { + const action = { + type: ACTION_UPDATE_STAGE, + payload: { stageId: 1, name: 'stage-1-modified' }, + }; + + state = { + status: expect.any(String), + serverState: expect.any(Object), + clientState: { + currentWorkflow: { data: WORKFLOWS_FIXTURE[0], isDirty: false }, + }, + }; + + expect(reducer(state, action)).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + data: expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: 1, + name: 'stage-1-modified', + }, + ]), + }), + }), + }), + }) + ); + }); + + test('properly compare serverState and clientState and set isDirty accordingly', () => { + const actionAddStage = { + type: ACTION_ADD_STAGE, + payload: { name: 'something' }, + }; + + state = { + status: expect.any(String), + serverState: { + currentWorkflow: WORKFLOWS_FIXTURE[0], + }, + clientState: { + currentWorkflow: { data: WORKFLOWS_FIXTURE[0], isDirty: false }, + }, + }; + + state = reducer(state, actionAddStage); + + expect(state).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + isDirty: true, + }), + }), + }) + ); + + const actionDeleteStage = { + type: ACTION_DELETE_STAGE, + payload: { stageId: 3 }, + }; + + state = reducer(state, actionDeleteStage); + + expect(state).toStrictEqual( + expect.objectContaining({ + clientState: expect.objectContaining({ + currentWorkflow: expect.objectContaining({ + isDirty: false, + }), + }), + }) + ); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js new file mode 100644 index 0000000000..a576d61852 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/tests/ReviewWorkflows.test.js @@ -0,0 +1,221 @@ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; +import { QueryClientProvider, QueryClient } from 'react-query'; +import userEvent from '@testing-library/user-event'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { useNotification } from '@strapi/helper-plugin'; +import { ThemeProvider, lightTheme } from '@strapi/design-system'; + +import configureStore from '../../../../../../../admin/src/core/store/configureStore'; +import ReviewWorkflowsPage from '..'; +import { reducer } from '../reducer'; + +jest.mock('@strapi/helper-plugin', () => ({ + ...jest.requireActual('@strapi/helper-plugin'), + useNotification: jest.fn().mockReturnValue(jest.fn()), + useTracking: jest.fn().mockReturnValue({ trackUsage: jest.fn() }), + // eslint-disable-next-line react/prop-types + CheckPagePermissions({ children }) { + return children; + }, +})); + +let SHOULD_ERROR = false; + +const FIXTURE_WORKFLOW = { + id: 1, + stages: [ + { + id: 1, + name: 'stage-1', + }, + ], +}; + +const server = setupServer( + rest.get('*/review-workflows/workflows', (req, res, ctx) => { + return res( + ctx.json({ + data: [FIXTURE_WORKFLOW], + }) + ); + }), + + rest.put(`*/review-workflows/workflows/${FIXTURE_WORKFLOW.id}/stages`, (req, res, ctx) => { + if (SHOULD_ERROR) { + return res(ctx.status(500), ctx.json({})); + } + + return res(ctx.json({})); + }) +); + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const ComponentFixture = () => { + const store = configureStore([], [reducer]); + + return ( + + + + + + + + + + ); +}; + +const setup = (props) => { + return { + ...render(), + user: userEvent.setup(), + }; +}; + +describe('Admin | Settings | Review Workflow | ReviewWorkflowsPage', () => { + beforeAll(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('handle initial loading state', () => { + const { getByText } = setup(); + + expect(getByText('0 stages')).toBeInTheDocument(); + expect(getByText('Workflow is loading')).toBeInTheDocument(); + }); + + test('loading state is not present', () => { + const { queryByText } = setup(); + + expect(queryByText('Workflow is loading')).not.toBeInTheDocument(); + }); + + test('display stages', async () => { + const { getByText } = setup(); + + await waitFor(() => expect(getByText('1 stage')).toBeInTheDocument()); + expect(getByText('stage-1')).toBeInTheDocument(); + }); + + test('Save button is disabled by default', () => { + const { getByRole } = setup(); + + const saveButton = getByRole('button', { name: /save/i }); + + expect(saveButton).toBeInTheDocument(); + expect(saveButton.getAttribute('disabled')).toBeDefined(); + }); + + test('Save button is enabled after a stage has been added', async () => { + const { user, getByText, getByRole } = setup(); + + await user.click( + getByRole('button', { + name: /add new stage/i, + }) + ); + + const saveButton = getByRole('button', { name: /save/i }); + + expect(getByText('2 stages')).toBeInTheDocument(); + expect(saveButton.hasAttribute('disabled')).toBeFalsy(); + }); + + test('Successful Stage update', async () => { + const toggleNotification = useNotification(); + const { user, getByRole } = setup(); + + await user.click( + getByRole('button', { + name: /add new stage/i, + }) + ); + + fireEvent.change(getByRole('textbox', { name: /stage name/i }), { + target: { value: 'stage-2' }, + }); + + await act(async () => { + await user.click(getByRole('button', { name: /save/i })); + }); + + expect(toggleNotification).toBeCalledWith({ + type: 'success', + message: expect.any(Object), + }); + }); + + test('Stage update with error', async () => { + SHOULD_ERROR = true; + const toggleNotification = useNotification(); + const { user, getByRole } = setup(); + + await user.click( + getByRole('button', { + name: /add new stage/i, + }) + ); + + fireEvent.change(getByRole('textbox', { name: /stage name/i }), { + target: { value: 'stage-2' }, + }); + + await act(async () => { + await user.click(getByRole('button', { name: /save/i })); + }); + + expect(toggleNotification).toBeCalledWith({ + type: 'warning', + message: expect.any(String), + }); + }); + + test('Does not show a delete button if only stage is left', () => { + const { queryByRole } = setup(); + + expect(queryByRole('button', { name: /delete stage/i })).not.toBeInTheDocument(); + }); + + test('Show confirmation dialog when a stage was deleted', async () => { + const { user, getByRole, getAllByRole } = setup(); + + await user.click( + getByRole('button', { + name: /add new stage/i, + }) + ); + + await user.type(getByRole('textbox', { name: /stage name/i }), 'stage-2'); + + const deleteButtons = getAllByRole('button', { name: /delete stage/i }); + + await user.click(deleteButtons[0]); + + await act(async () => { + await user.click(getByRole('button', { name: /save/i })); + }); + + expect(getByRole('heading', { name: /confirmation/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js new file mode 100644 index 0000000000..ed87817568 --- /dev/null +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/ReviewWorkflows/utils/getWorkflowValidationSchema.js @@ -0,0 +1,25 @@ +import * as yup from 'yup'; + +export function getWorkflowValidationSchema({ formatMessage }) { + return yup.object({ + stages: yup.array().of( + yup.object().shape({ + name: yup + .string() + .required( + formatMessage({ + id: 'Settings.review-workflows.validation.stage.name', + defaultMessage: 'Name is required', + }) + ) + .max( + 255, + formatMessage({ + id: 'Settings.review-workflows.validation.stage.max-length', + defaultMessage: 'Name can not be longer than 255 characters', + }) + ), + }) + ), + }); +} diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/pages/SingleSignOn/tests/index.test.js b/packages/core/admin/ee/admin/pages/SettingsPage/pages/SingleSignOn/tests/index.test.js index 315bbc9df0..ed5cfa0953 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/pages/SingleSignOn/tests/index.test.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/pages/SingleSignOn/tests/index.test.js @@ -11,7 +11,7 @@ import { SingleSignOn } from '../index'; jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), useTracking: jest.fn(() => ({ trackUsage: jest.fn() })), - useNotification: jest.fn(), + useNotification: jest.fn().mockImplementation(() => jest.fn()), useOverlayBlocker: jest.fn(() => ({ lockApp: jest.fn(), unlockApp: jest.fn() })), useRBAC: jest.fn(), useFocusWhenNavigate: jest.fn(), diff --git a/packages/core/admin/ee/admin/pages/SettingsPage/utils/customRoutes.js b/packages/core/admin/ee/admin/pages/SettingsPage/utils/customRoutes.js index fe47f3026c..dd800e6ad2 100644 --- a/packages/core/admin/ee/admin/pages/SettingsPage/utils/customRoutes.js +++ b/packages/core/admin/ee/admin/pages/SettingsPage/utils/customRoutes.js @@ -1,6 +1,6 @@ const routes = []; -if (strapi.features.isEnabled(strapi.features.SSO)) { +if (window.strapi.features.isEnabled(window.strapi.features.SSO)) { routes.push({ async Component() { const component = await import( @@ -14,7 +14,21 @@ if (strapi.features.isEnabled(strapi.features.SSO)) { }); } -if (strapi.features.isEnabled(strapi.features.AUDIT_LOGS)) { +if (window.strapi.features.isEnabled(window.strapi.features.REVIEW_WORKFLOWS)) { + routes.push({ + async Component() { + const component = await import( + /* webpackChunkName: "review-workflows-settings" */ '../pages/ReviewWorkflows' + ); + + return component; + }, + to: '/settings/review-workflows', + exact: true, + }); +} + +if (window.strapi.features.isEnabled(window.strapi.features.AUDIT_LOGS)) { routes.push({ async Component() { const component = await import( diff --git a/packages/core/admin/ee/admin/permissions/customPermissions.js b/packages/core/admin/ee/admin/permissions/customPermissions.js index 1488243900..b3bfec4171 100644 --- a/packages/core/admin/ee/admin/permissions/customPermissions.js +++ b/packages/core/admin/ee/admin/permissions/customPermissions.js @@ -4,6 +4,9 @@ const customPermissions = { main: [{ action: 'admin::audit-logs.read', subject: null }], read: [{ action: 'admin::audit-logs.read', subject: null }], }, + 'review-workflows': { + main: [{ action: 'admin::review-workflows.read', subject: null }], + }, sso: { main: [{ action: 'admin::provider-login.read', subject: null }], read: [{ action: 'admin::provider-login.read', subject: null }], diff --git a/packages/core/admin/ee/server/bootstrap.js b/packages/core/admin/ee/server/bootstrap.js index 657d50e3d5..678ce883e9 100644 --- a/packages/core/admin/ee/server/bootstrap.js +++ b/packages/core/admin/ee/server/bootstrap.js @@ -20,6 +20,19 @@ module.exports = async () => { await actionProvider.registerMany(actions.auditLogs); } + if (features.isEnabled('review-workflows')) { + await persistTablesWithPrefix('strapi_workflows'); + + const { bootstrap: rwBootstrap } = getService('review-workflows'); + + await rwBootstrap(); + await actionProvider.registerMany(actions.reviewWorkflows); + + // Decorate the entity service with review workflow logic + const { decorator } = getService('review-workflows-decorator'); + strapi.entityService.decorate(decorator); + } + await getService('seat-enforcement').seatEnforcementWorkflow(); await executeCEBootstrap(); diff --git a/packages/core/admin/ee/server/config/admin-actions.js b/packages/core/admin/ee/server/config/admin-actions.js index cc95155997..77a65f7b6c 100644 --- a/packages/core/admin/ee/server/config/admin-actions.js +++ b/packages/core/admin/ee/server/config/admin-actions.js @@ -29,4 +29,14 @@ module.exports = { subCategory: 'options', }, ], + reviewWorkflows: [ + { + uid: 'review-workflows.read', + displayName: 'Read', + pluginName: 'admin', + section: 'settings', + category: 'review workflows', + subCategory: 'options', + }, + ], }; diff --git a/packages/core/admin/ee/server/constants/default-stages.json b/packages/core/admin/ee/server/constants/default-stages.json new file mode 100644 index 0000000000..ee43e5b9f8 --- /dev/null +++ b/packages/core/admin/ee/server/constants/default-stages.json @@ -0,0 +1,14 @@ +[ + { + "name": "To do" + }, + { + "name": "Ready to review" + }, + { + "name": "In progress" + }, + { + "name": "Reviewed" + } +] diff --git a/packages/generators/generators/lib/files/js/plugin/admin/src/translations/en.json b/packages/core/admin/ee/server/constants/default-workflow.json similarity index 100% rename from packages/generators/generators/lib/files/js/plugin/admin/src/translations/en.json rename to packages/core/admin/ee/server/constants/default-workflow.json diff --git a/packages/core/admin/ee/server/constants/workflows.js b/packages/core/admin/ee/server/constants/workflows.js new file mode 100644 index 0000000000..b9d04fba51 --- /dev/null +++ b/packages/core/admin/ee/server/constants/workflows.js @@ -0,0 +1,8 @@ +'use strict'; + +// TODO concatenate admin + content type singular name +module.exports = { + WORKFLOW_MODEL_UID: 'admin::workflow', + STAGE_MODEL_UID: 'admin::workflow-stage', + ENTITY_STAGE_ATTRIBUTE: 'strapi_reviewWorkflows_stage', +}; diff --git a/packages/core/admin/ee/server/content-types/index.js b/packages/core/admin/ee/server/content-types/index.js new file mode 100644 index 0000000000..ca78526219 --- /dev/null +++ b/packages/core/admin/ee/server/content-types/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const workflow = require('./workflow'); +const workflowStage = require('./workflow-stage'); + +module.exports = { + workflow, + 'workflow-stage': workflowStage, +}; diff --git a/packages/core/admin/ee/server/content-types/workflow-stage/index.js b/packages/core/admin/ee/server/content-types/workflow-stage/index.js new file mode 100644 index 0000000000..48ab4118ad --- /dev/null +++ b/packages/core/admin/ee/server/content-types/workflow-stage/index.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = { + schema: { + collectionName: 'strapi_workflows_stages', + info: { + name: 'Workflow Stage', + description: '', + singularName: 'workflow-stage', + pluralName: 'workflow-stages', + displayName: 'Stages', + }, + options: {}, + pluginOptions: { + 'content-manager': { + visible: false, + }, + 'content-type-builder': { + visible: false, + }, + }, + attributes: { + name: { + type: 'string', + configurable: false, + }, + workflow: { + type: 'relation', + target: 'admin::workflow', + relation: 'manyToOne', + inversedBy: 'stages', + configurable: false, + }, + }, + }, +}; diff --git a/packages/core/admin/ee/server/content-types/workflow/index.js b/packages/core/admin/ee/server/content-types/workflow/index.js new file mode 100644 index 0000000000..d80fbc0938 --- /dev/null +++ b/packages/core/admin/ee/server/content-types/workflow/index.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = { + schema: { + collectionName: 'strapi_workflows', + info: { + name: 'Workflow', + description: '', + singularName: 'workflow', + pluralName: 'workflows', + displayName: 'Workflow', + }, + options: {}, + pluginOptions: { + 'content-manager': { + visible: false, + }, + 'content-type-builder': { + visible: false, + }, + }, + attributes: { + stages: { + type: 'relation', + target: 'admin::workflow-stage', + relation: 'oneToMany', + mappedBy: 'workflow', + }, + }, + }, +}; diff --git a/packages/core/admin/ee/server/controllers/authentication/middlewares.js b/packages/core/admin/ee/server/controllers/authentication/middlewares.js index deb669844b..32670e7d63 100644 --- a/packages/core/admin/ee/server/controllers/authentication/middlewares.js +++ b/packages/core/admin/ee/server/controllers/authentication/middlewares.js @@ -95,13 +95,14 @@ const redirectWithAuth = (ctx) => { params: { provider }, } = ctx; const redirectUrls = utils.getPrefixedRedirectUrls(); + const domain = strapi.config.get('admin.auth.domain'); const { user } = ctx.state; const jwt = getService('token').createJwtToken(user); const isProduction = strapi.config.get('environment') === 'production'; - const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true }; + const cookiesOptions = { httpOnly: false, secure: isProduction, overwrite: true, domain }; const sanitizedUser = getService('user').sanitizeUser(user); strapi.eventHub.emit('admin.auth.success', { user: sanitizedUser, provider }); diff --git a/packages/core/admin/ee/server/controllers/index.js b/packages/core/admin/ee/server/controllers/index.js index 68d6f1cb02..70eb80893b 100644 --- a/packages/core/admin/ee/server/controllers/index.js +++ b/packages/core/admin/ee/server/controllers/index.js @@ -6,4 +6,6 @@ module.exports = { user: require('./user'), auditLogs: require('./audit-logs'), admin: require('./admin'), + workflows: require('./workflows'), + stages: require('./workflows/stages'), }; diff --git a/packages/core/admin/ee/server/controllers/workflows/index.js b/packages/core/admin/ee/server/controllers/workflows/index.js new file mode 100644 index 0000000000..4c35700b33 --- /dev/null +++ b/packages/core/admin/ee/server/controllers/workflows/index.js @@ -0,0 +1,36 @@ +'use strict'; + +const { getService } = require('../../utils'); + +module.exports = { + /** + * List all workflows + * @param {import('koa').BaseContext} ctx - koa context + */ + async find(ctx) { + const { populate } = ctx.query; + const workflowService = getService('workflows'); + const data = await workflowService.find({ + populate, + }); + + ctx.body = { + data, + }; + }, + /** + * Get one workflow based on its id contained in request parameters + * @param {import('koa').BaseContext} ctx - koa context + */ + async findById(ctx) { + const { id } = ctx.params; + const { populate } = ctx.query; + + const workflowService = getService('workflows'); + const data = await workflowService.findById(id, { populate }); + + ctx.body = { + data, + }; + }, +}; diff --git a/packages/core/admin/ee/server/controllers/workflows/stages/index.js b/packages/core/admin/ee/server/controllers/workflows/stages/index.js new file mode 100644 index 0000000000..9085ae7a5b --- /dev/null +++ b/packages/core/admin/ee/server/controllers/workflows/stages/index.js @@ -0,0 +1,102 @@ +'use strict'; + +const { ApplicationError } = require('@strapi/utils/lib/errors'); +const { getService } = require('../../../utils'); +const { hasReviewWorkflow } = require('../../../utils/review-workflows'); +const { + validateUpdateStages, + validateUpdateStageOnEntity, +} = require('../../../validation/review-workflows'); + +module.exports = { + /** + * List all stages + * @param {import('koa').BaseContext} ctx - koa context + */ + async find(ctx) { + const { workflow_id: workflowId } = ctx.params; + const { populate } = ctx.query; + const stagesService = getService('stages'); + + const data = await stagesService.find({ + workflowId, + populate, + }); + + ctx.body = { + data, + }; + }, + /** + * Get one stage + * @param {import('koa').BaseContext} ctx - koa context + */ + async findById(ctx) { + const { id, workflow_id: workflowId } = ctx.params; + const { populate } = ctx.query; + const stagesService = getService('stages'); + + const data = await stagesService.findById(id, { + workflowId, + populate, + }); + + ctx.body = { + data, + }; + }, + + /** + * Replace all stages in a workflow + * @param {import('koa').BaseContext} ctx - koa context + * + */ + async replace(ctx) { + const { workflow_id: workflowId } = ctx.params; + const stagesService = getService('stages'); + const { + body: { data: stages }, + } = ctx.request; + + const stagesValidated = await validateUpdateStages(stages); + + const data = await stagesService.replaceWorkflowStages(workflowId, stagesValidated); + + ctx.body = { data }; + }, + + /** + * Updates an entity's stage. + * @async + * @param {Object} ctx - The Koa context object. + * @param {Object} ctx.params - An object containing the parameters from the request URL. + * @param {string} ctx.params.model_uid - The model UID of the entity. + * @param {string} ctx.params.id - The ID of the entity to update. + * @param {Object} ctx.request.body.data - Optional data object containing the new stage ID for the entity. + * @param {string} ctx.request.body.data.id - The ID of the new stage for the entity. + * @throws {ApplicationError} If review workflows is not activated on the specified model UID. + * @throws {ValidationError} If the `data` object in the request body fails to pass validation. + * @returns {Promise} A promise that resolves when the entity's stage has been updated. + */ + async updateEntity(ctx) { + const stagesService = getService('stages'); + const { model_uid: modelUID, id: entityIdString } = ctx.params; + const entityId = Number(entityIdString); + + const { id: stageId } = await validateUpdateStageOnEntity( + ctx.request?.body?.data, + 'You should pass an id to the body of the put request.' + ); + + if (!hasReviewWorkflow({ strapi }, modelUID)) { + throw new ApplicationError(`Review workflows is not activated on ${modelUID}.`); + } + + // TODO When multiple workflows are possible, check if the stage is part of the right one + // Didn't need this today as their can only be one workflow + + const data = await stagesService.updateEntity({ id: entityId, modelUID }, stageId); + + ctx.body = { data }; + }, +}; diff --git a/packages/core/admin/ee/server/index.js b/packages/core/admin/ee/server/index.js index 32a7c56aed..34c400cf04 100644 --- a/packages/core/admin/ee/server/index.js +++ b/packages/core/admin/ee/server/index.js @@ -2,6 +2,7 @@ module.exports = { register: require('./register'), + contentTypes: require('./content-types'), bootstrap: require('./bootstrap'), destroy: require('./destroy'), routes: require('./routes'), diff --git a/packages/core/admin/ee/server/middlewares/__tests__/review-workflows.test.js b/packages/core/admin/ee/server/middlewares/__tests__/review-workflows.test.js new file mode 100644 index 0000000000..49bdf3c914 --- /dev/null +++ b/packages/core/admin/ee/server/middlewares/__tests__/review-workflows.test.js @@ -0,0 +1,40 @@ +'use strict'; + +const reviewWorkflowsMiddlewares = require('../review-workflows'); + +const strapiMock = { + server: { + router: { + use: jest.fn(), + }, + }, +}; +describe('Review workflows middlewares', () => { + describe('contentTypeMiddleware', () => { + test('Should add middleware to content-type-builder route', () => { + const ctxMock = { + method: 'PUT', + request: { + body: { + contentType: { + reviewWorkflows: true, + }, + }, + }, + }; + const nextMock = () => {}; + strapiMock.server.router.use.mockImplementationOnce((route, callback) => + callback(ctxMock, nextMock) + ); + reviewWorkflowsMiddlewares.contentTypeMiddleware(strapiMock); + + expect(strapiMock.server.router.use).toBeCalled(); + expect(strapiMock.server.router.use).toBeCalledWith( + '/content-type-builder/content-types/:uid?', + expect.any(Function) + ); + expect(ctxMock.request.body.contentType.reviewWorkflows).toBeUndefined(); + expect(ctxMock.request.body.contentType.options?.reviewWorkflows).toBe(true); + }); + }); +}); diff --git a/packages/core/admin/ee/server/middlewares/review-workflows.js b/packages/core/admin/ee/server/middlewares/review-workflows.js new file mode 100644 index 0000000000..41972a1ed3 --- /dev/null +++ b/packages/core/admin/ee/server/middlewares/review-workflows.js @@ -0,0 +1,40 @@ +'use strict'; + +const { set } = require('lodash/fp'); + +module.exports = { + contentTypeMiddleware, +}; + +/** + * A Strapi middleware function that adds support for review workflows. + * + * Why is it needed ? + * For now, the admin panel cannot have anything but top-level attributes in the content-type for options. + * But we need the CE part to be agnostics from Review Workflow (which is an EE feature). + * CE handle the `options` object, that's why we move the reviewWorkflows boolean to the options object. + * + * @param {object} strapi - The Strapi instance. + */ +function contentTypeMiddleware(strapi) { + /** + * A middleware function that moves the `reviewWorkflows` attribute from the top level of + * the request body to the `options` object within the request body. + * + * @param {object} ctx - The Koa context object. + */ + const moveReviewWorkflowOption = (ctx) => { + // Move reviewWorkflows to options.reviewWorkflows + const { reviewWorkflows, ...contentType } = ctx.request.body.contentType; + + if (typeof reviewWorkflows === 'boolean') { + ctx.request.body.contentType = set('options.reviewWorkflows', reviewWorkflows, contentType); + } + }; + strapi.server.router.use('/content-type-builder/content-types/:uid?', (ctx, next) => { + if (ctx.method === 'PUT' || ctx.method === 'POST') { + moveReviewWorkflowOption(ctx); + } + return next(); + }); +} diff --git a/packages/core/admin/ee/server/register.js b/packages/core/admin/ee/server/register.js index 5a8ae909e9..72726acbb1 100644 --- a/packages/core/admin/ee/server/register.js +++ b/packages/core/admin/ee/server/register.js @@ -1,8 +1,11 @@ 'use strict'; +const { features } = require('@strapi/strapi/lib/utils/ee'); const executeCERegister = require('../../server/register'); const migrateAuditLogsTable = require('./migrations/audit-logs-table'); const createAuditLogsService = require('./services/audit-logs'); +const reviewWorkflowsMiddlewares = require('./middlewares/review-workflows'); +const { getService } = require('./utils'); module.exports = async ({ strapi }) => { const auditLogsIsEnabled = strapi.config.get('admin.auditLogs.enabled', true); @@ -13,6 +16,11 @@ module.exports = async ({ strapi }) => { strapi.container.register('audit-logs', auditLogsService); await auditLogsService.register(); } + if (features.isEnabled('review-workflows')) { + const reviewWorkflowService = getService('review-workflows'); + reviewWorkflowsMiddlewares.contentTypeMiddleware(strapi); + await reviewWorkflowService.register(); + } await executeCERegister({ strapi }); }; diff --git a/packages/core/admin/ee/server/routes/index.js b/packages/core/admin/ee/server/routes/index.js index 180061cb89..e789639768 100644 --- a/packages/core/admin/ee/server/routes/index.js +++ b/packages/core/admin/ee/server/routes/index.js @@ -122,4 +122,108 @@ module.exports = [ ], }, }, + + // Review workflow + { + method: 'GET', + path: '/review-workflows/workflows', + handler: 'workflows.find', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, + { + method: 'GET', + path: '/review-workflows/workflows/:id', + handler: 'workflows.findById', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, + { + method: 'GET', + path: '/review-workflows/workflows/:workflow_id/stages', + handler: 'stages.find', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, + { + method: 'PUT', + path: '/review-workflows/workflows/:workflow_id/stages', + handler: 'stages.replace', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, + { + method: 'GET', + path: '/review-workflows/workflows/:workflow_id/stages/:id', + handler: 'stages.findById', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, + { + method: 'PUT', + path: '/content-manager/(collection|single)-types/:model_uid/:id/stage', + handler: 'stages.updateEntity', + config: { + middlewares: [enableFeatureMiddleware('review-workflows')], + policies: [ + 'admin::isAuthenticatedAdmin', + { + name: 'admin::hasPermissions', + config: { + actions: ['admin::review-workflows.read'], + }, + }, + ], + }, + }, ]; diff --git a/packages/core/admin/ee/server/services/__tests__/review-workflows.test.js b/packages/core/admin/ee/server/services/__tests__/review-workflows.test.js new file mode 100644 index 0000000000..423a9204dd --- /dev/null +++ b/packages/core/admin/ee/server/services/__tests__/review-workflows.test.js @@ -0,0 +1,134 @@ +'use strict'; + +const reviewWorkflowsServiceFactory = require('../review-workflows/review-workflows'); +const { ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows'); + +const workflowMock = { + id: 1, +}; +const stagesMock = [ + { + id: 1, + name: 'stage 1', + }, + { + id: 2, + name: 'stage 2', + }, + { + id: 3, + name: 'stage 3', + }, +]; + +const workflowsServiceMock = { + count: jest.fn(() => 0), + create: jest.fn(() => workflowMock), +}; +const stagesServiceMock = { + count: jest.fn(() => 0), + createMany: jest.fn(() => stagesMock), +}; + +const queryMock = { + findOne: jest.fn(), +}; + +const contentTypesMock = { + test1: { + options: { + reviewWorkflows: false, + }, + attributes: {}, + }, + test2: { + options: { + reviewWorkflows: true, + }, + attributes: {}, + }, +}; + +const containerMock = { + get: jest.fn().mockReturnThis(), + extend: jest.fn(), +}; + +const hookMock = jest.fn().mockReturnValue({ register: jest.fn() }); + +const strapiMock = { + contentTypes: contentTypesMock, + container: containerMock, + hook: hookMock, + query: jest.fn(() => queryMock), + service(serviceName) { + switch (serviceName) { + case 'admin::stages': + return stagesServiceMock; + case 'admin::workflows': + return workflowsServiceMock; + default: + return null; + } + }, +}; + +const reviewWorkflowsService = reviewWorkflowsServiceFactory({ strapi: strapiMock }); + +describe('Review workflows service', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('bootstrap', () => { + test('Without stages or workflows in DB', async () => { + await reviewWorkflowsService.bootstrap(); + + expect(workflowsServiceMock.count).toBeCalled(); + expect(stagesServiceMock.count).toBeCalled(); + + expect(stagesServiceMock.createMany).toBeCalled(); + expect(workflowsServiceMock.create).toBeCalled(); + }); + test('With a workflow in DB', async () => { + workflowsServiceMock.count.mockResolvedValue(1); + await reviewWorkflowsService.bootstrap(); + + expect(workflowsServiceMock.count).toBeCalled(); + expect(stagesServiceMock.count).toBeCalled(); + + expect(stagesServiceMock.createMany).not.toBeCalled(); + expect(workflowsServiceMock.create).not.toBeCalled(); + }); + test('With stages in DB', async () => { + stagesServiceMock.count.mockResolvedValue(5); + await reviewWorkflowsService.bootstrap(); + + expect(workflowsServiceMock.count).toBeCalled(); + expect(stagesServiceMock.count).toBeCalled(); + + expect(stagesServiceMock.createMany).not.toBeCalled(); + expect(workflowsServiceMock.create).not.toBeCalled(); + }); + }); + describe('register', () => { + test('Content types with review workflows options should have a new attribute', async () => { + await reviewWorkflowsService.register(); + expect(containerMock.extend).toHaveBeenCalledTimes(1); + expect(containerMock.extend).not.toHaveBeenCalledWith('test1', expect.any(Function)); + expect(containerMock.extend).toHaveBeenCalledWith('test2', expect.any(Function)); + + const extendFunc = containerMock.extend.mock.calls[0][1]; + + expect(extendFunc({})).toEqual({ + attributes: { + [ENTITY_STAGE_ATTRIBUTE]: expect.objectContaining({ + relation: 'oneToOne', + target: 'admin::workflow-stage', + type: 'relation', + }), + }, + }); + }); + }); +}); diff --git a/packages/core/admin/ee/server/services/__tests__/stages.test.js b/packages/core/admin/ee/server/services/__tests__/stages.test.js new file mode 100644 index 0000000000..7bdc09174a --- /dev/null +++ b/packages/core/admin/ee/server/services/__tests__/stages.test.js @@ -0,0 +1,270 @@ +'use strict'; + +jest.mock('@strapi/strapi/lib/utils/ee', () => { + const eeModule = () => true; + + Object.assign(eeModule, { + features: { + isEnabled() { + return true; + }, + getEnabled() { + return ['review-workflows']; + }, + }, + }); + + return eeModule; +}); + +const { cloneDeep } = require('lodash/fp'); + +const stageFactory = require('../review-workflows/stages'); +const { STAGE_MODEL_UID } = require('../../constants/workflows'); + +const stageMock = { + id: 1, + name: 'test', + workflow: 1, +}; + +const relatedUID = 'uid'; +const workflowMock = { + id: 1, + stages: [ + stageMock, + { id: 2, name: 'in progress', workflow: 1 }, + { + id: 3, + name: 'ready to review', + related: [{ id: 3, __type: relatedUID }], + workflow: 1, + }, + { + id: 4, + name: 'done', + related: [ + { id: 1, __type: relatedUID }, + { id: 2, __type: relatedUID }, + ], + workflow: 1, + }, + ], +}; + +const entityServiceMock = { + findOne: jest.fn((uid, id) => workflowMock.stages.find((stage) => stage.id === id) || { id }), + findMany: jest.fn(() => [stageMock]), + create: jest.fn((uid, { data }) => ({ + ...data, + id: data?.id || Math.floor(Math.random() * 1000), + })), + update: jest.fn((uid, id, { data }) => data), + delete: jest.fn(() => true), +}; +const servicesMock = { + 'admin::workflows': { + findById: jest.fn(() => workflowMock), + update: jest.fn((id, data) => data), + }, + 'admin::review-workflows-metrics': { + sendDidCreateStage: jest.fn(), + sendDidEditStage: jest.fn(), + sendDidDeleteStage: jest.fn(), + sendDidChangeEntryStage: jest.fn(), + }, +}; + +const queryUpdateMock = jest.fn(() => Promise.resolve()); + +const strapiMock = { + query: jest.fn(() => ({ + findOne: jest.fn(() => workflowMock), + })), + entityService: entityServiceMock, + service: jest.fn((serviceName) => { + return servicesMock[serviceName]; + }), + db: { + transaction: jest.fn((func) => func({})), + query: jest.fn(() => ({ + updateMany: queryUpdateMock, + })), + metadata: { + get: () => ({ + tableName: 'test', + attributes: { + strapi_reviewWorkflows_stage: { + joinColumn: { + name: 'strapi_reviewWorkflows_stage_id', + }, + }, + }, + }), + }, + }, + contentTypes: { + 'api::shop.shop': { + kind: 'collectionType', + collectionName: 'shop', + options: { + reviewWorkflows: true, + }, + }, + }, +}; + +const stagesService = stageFactory({ strapi: strapiMock }); + +describe('Review workflows - Stages service', () => { + let mockUpdateEntitiesStage; + beforeEach(() => { + mockUpdateEntitiesStage = jest + .spyOn(stagesService, 'updateEntitiesStage') + .mockImplementation(() => { + return Promise.resolve(); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockUpdateEntitiesStage.mockRestore(); + }); + + describe('find', () => { + test('Should call entityService with the right model UID and ID', async () => { + stagesService.find({ workflowId: 1 }); + + expect(entityServiceMock.findOne).not.toBeCalled(); + expect(entityServiceMock.findMany).toBeCalled(); + expect(entityServiceMock.findMany).toBeCalledWith(STAGE_MODEL_UID, { + filters: { workflow: 1 }, + }); + }); + }); + describe('findById', () => { + test('Should call entityService with the right model UID', async () => { + stagesService.findById(1, { workflowId: 1 }); + + expect(entityServiceMock.findMany).not.toBeCalled(); + expect(entityServiceMock.findOne).toBeCalled(); + expect(entityServiceMock.findOne).toBeCalledWith(STAGE_MODEL_UID, 1, {}); + }); + }); + describe('replaceWorkflowStages', () => { + test('Should create a new stage and assign it to workflow', async () => { + await stagesService.replaceWorkflowStages(1, [ + ...workflowMock.stages, + { + name: 'to publish', + }, + ]); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).toBeCalled(); + expect(entityServiceMock.update).not.toBeCalled(); + expect(entityServiceMock.delete).not.toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalled(); + }); + test('Should update a stage contained in the workflow', async () => { + const updateStages = cloneDeep(workflowMock.stages); + updateStages[0].name = `${updateStages[0].name}(new value)`; + + await stagesService.replaceWorkflowStages(1, updateStages); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).not.toBeCalled(); + expect(entityServiceMock.update).toBeCalled(); + expect(entityServiceMock.delete).not.toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalledWith(workflowMock.id, { + stages: updateStages.map((stage) => stage.id), + }); + }); + test('Should delete a stage contained in the workflow', async () => { + const selectedIndexes = [0, 2, 3]; + await stagesService.replaceWorkflowStages( + 1, + selectedIndexes.map((index) => workflowMock.stages[index]) + ); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).not.toBeCalled(); + expect(entityServiceMock.update).not.toBeCalled(); + expect(entityServiceMock.delete).toBeCalled(); + + expect(servicesMock['admin::workflows'].update).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalledWith(workflowMock.id, { + stages: selectedIndexes.map((index) => workflowMock.stages[index].id), + }); + }); + + test('Should move entities in a deleted stage to the previous stage', async () => { + await stagesService.replaceWorkflowStages(1, workflowMock.stages.slice(0, 3)); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).not.toBeCalled(); + expect(entityServiceMock.delete).toBeCalled(); + + // Here we are only deleting the stage containing related IDs 1 & 2 + expect(stagesService.updateEntitiesStage).toHaveBeenCalledWith('api::shop.shop', { + fromStageId: workflowMock.stages[3].id, + toStageId: workflowMock.stages[2].id, + }); + + expect(servicesMock['admin::workflows'].update).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalledWith(workflowMock.id, { + stages: [workflowMock.stages[0].id, workflowMock.stages[1].id, workflowMock.stages[2].id], + }); + }); + + test('When deleting all stages, all entities should be moved to the new stage', async () => { + const newStageID = 10; + await stagesService.replaceWorkflowStages(1, [ + { id: newStageID, name: 'newStage', workflow: 1 }, + ]); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).toBeCalled(); + expect(entityServiceMock.delete).toBeCalled(); + + // Here we are deleting all stages and expecting all entities to be moved to the new stage + for (const stage of workflowMock.stages) { + expect(stagesService.updateEntitiesStage).toHaveBeenCalledWith('api::shop.shop', { + fromStageId: stage.id, + toStageId: newStageID, + }); + } + + expect(servicesMock['admin::workflows'].update).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalledWith(workflowMock.id, { + stages: [newStageID], + }); + + mockUpdateEntitiesStage.mockRestore(); + }); + + test('New stage + updated + deleted', async () => { + await stagesService.replaceWorkflowStages(1, [ + workflowMock.stages[0], + { id: workflowMock.stages[1].id, name: 'new_name' }, + { name: 'new stage' }, + { name: 'new stage2' }, + ]); + + expect(servicesMock['admin::workflows'].findById).toBeCalled(); + expect(entityServiceMock.create).toBeCalled(); + expect(entityServiceMock.update).toBeCalled(); + expect(entityServiceMock.delete).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalled(); + expect(servicesMock['admin::workflows'].update).toBeCalledWith(workflowMock.id, { + stages: [ + workflowMock.stages[0].id, + workflowMock.stages[1].id, + expect.any(Number), + expect.any(Number), + ], + }); + }); + }); +}); diff --git a/packages/core/admin/ee/server/services/__tests__/workflows.test.js b/packages/core/admin/ee/server/services/__tests__/workflows.test.js new file mode 100644 index 0000000000..2de6ebbe4f --- /dev/null +++ b/packages/core/admin/ee/server/services/__tests__/workflows.test.js @@ -0,0 +1,61 @@ +'use strict'; + +jest.mock('@strapi/strapi/lib/utils/ee', () => { + const eeModule = () => true; + + Object.assign(eeModule, { + features: { + isEnabled() { + return true; + }, + getEnabled() { + return ['review-workflows']; + }, + }, + }); + + return eeModule; +}); + +const workflowsServiceFactory = require('../review-workflows/workflows'); +const { WORKFLOW_MODEL_UID } = require('../../constants/workflows'); + +const workflowMock = { + id: 1, +}; + +const entityServiceMock = { + findOne: jest.fn(() => workflowMock), + findMany: jest.fn(() => [workflowMock]), +}; + +const strapiMock = { + entityService: entityServiceMock, +}; + +const workflowsService = workflowsServiceFactory({ strapi: strapiMock }); + +describe('Review workflows - Workflows service', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('find', () => { + test('Should call entityService with the right model UID and ID', async () => { + workflowsService.find({ opt1: 1 }); + + expect(entityServiceMock.findOne).not.toBeCalled(); + expect(entityServiceMock.findMany).toBeCalled(); + expect(entityServiceMock.findMany).toBeCalledWith(WORKFLOW_MODEL_UID, { opt1: 1 }); + }); + }); + describe('findById', () => { + test('Should call entityService with the right model UID', async () => { + workflowsService.findById(1, {}); + + expect(entityServiceMock.findMany).not.toBeCalled(); + expect(entityServiceMock.findOne).toBeCalled(); + expect(entityServiceMock.findOne).toBeCalledWith(WORKFLOW_MODEL_UID, 1, {}); + }); + }); +}); diff --git a/packages/core/admin/ee/server/services/index.js b/packages/core/admin/ee/server/services/index.js index e7e24ff5ed..abbb11dafd 100644 --- a/packages/core/admin/ee/server/services/index.js +++ b/packages/core/admin/ee/server/services/index.js @@ -5,4 +5,9 @@ module.exports = { role: require('./role'), user: require('./user'), 'seat-enforcement': require('./seat-enforcement'), + workflows: require('./review-workflows/workflows'), + stages: require('./review-workflows/stages'), + 'review-workflows': require('./review-workflows/review-workflows'), + 'review-workflows-decorator': require('./review-workflows/entity-service-decorator'), + 'review-workflows-metrics': require('./review-workflows/metrics'), }; diff --git a/packages/core/admin/ee/server/services/review-workflows/__tests__/entity-service-decorator.test.js b/packages/core/admin/ee/server/services/review-workflows/__tests__/entity-service-decorator.test.js new file mode 100644 index 0000000000..1b8d4b2bdf --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/__tests__/entity-service-decorator.test.js @@ -0,0 +1,148 @@ +'use strict'; + +const { omit } = require('lodash/fp'); +const { decorator } = require('../entity-service-decorator')(); + +jest.mock('../../../utils'); + +const rwModel = { + options: { + reviewWorkflows: true, + }, +}; + +const model = { + options: { + reviewWorkflows: false, + }, +}; + +const models = { + 'test-model': rwModel, + 'non-rw-model': model, +}; + +describe('Entity service decorator', () => { + beforeAll(() => { + global.strapi = { + getModel(uid) { + return models[uid || 'test-model']; + }, + query: () => ({ + findOne: () => ({ + id: 1, + stages: [{ id: 1 }], + }), + }), + }; + }); + + describe('Create', () => { + test('Calls original create for non review workflow content types', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + create: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { data: { title: 'title ' } }; + await service.create('non-rw-model', input); + + expect(defaultService.create).toHaveBeenCalledWith('non-rw-model', input); + }); + + test('Assigns default stage to new review workflow entity', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + create: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const input = { data: { title: 'title ' } }; + await service.create('test-model', input); + + expect(defaultService.create).toHaveBeenCalledWith('test-model', { + ...input, + data: { + ...input.data, + strapi_reviewWorkflows_stage: 1, + }, + }); + }); + }); + + describe('Update', () => { + test('Calls original update for non review workflow content types', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const id = 1; + const input = { data: { title: 'title ' } }; + await service.update('non-rw-model', id, input); + + expect(defaultService.update).toHaveBeenCalledWith('non-rw-model', id, input); + }); + + test('Assigns a stage to new review workflow entity', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const id = 1; + const input = { data: { title: 'title ', strapi_reviewWorkflows_stage: 1 } }; + await service.update('test-model', id, input); + + expect(defaultService.update).toHaveBeenCalledWith('test-model', id, { + ...input, + data: { + ...input.data, + strapi_reviewWorkflows_stage: 1, + }, + }); + }); + + test('Can not assign a null stage to new review workflow entity', async () => { + const entry = { + id: 1, + }; + + const defaultService = { + update: jest.fn(() => Promise.resolve(entry)), + }; + + const service = decorator(defaultService); + + const id = 1; + const input = { data: { title: 'title ', strapi_reviewWorkflows_stage: null } }; + await service.update('test-model', id, input); + + expect(defaultService.update).toHaveBeenCalledWith('test-model', id, { + ...input, + data: { + ...omit('strapi_reviewWorkflows_stage', input.data), + }, + }); + }); + }); +}); diff --git a/packages/core/admin/ee/server/services/review-workflows/entity-service-decorator.js b/packages/core/admin/ee/server/services/review-workflows/entity-service-decorator.js new file mode 100644 index 0000000000..3fd52fe4e1 --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/entity-service-decorator.js @@ -0,0 +1,54 @@ +'use strict'; + +const { isNil, isNull } = require('lodash/fp'); +const { ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows'); +const { hasReviewWorkflow, getDefaultWorkflow } = require('../../utils/review-workflows'); + +/** + * Assigns the entity data to the default workflow stage if no stage is present in the data + * @param {Object} data + * @returns + */ +const getDataWithStage = async (data) => { + if (!isNil(ENTITY_STAGE_ATTRIBUTE, data)) { + const defaultWorkflow = await getDefaultWorkflow({ strapi }); + return { ...data, [ENTITY_STAGE_ATTRIBUTE]: defaultWorkflow.stages[0].id }; + } + return data; +}; + +/** + * Decorates the entity service with RW business logic + * @param {object} service - entity service + */ +const decorator = (service) => ({ + async create(uid, opts = {}) { + const hasRW = hasReviewWorkflow({ strapi }, uid); + + if (!hasRW) { + return service.create.call(this, uid, opts); + } + + const data = await getDataWithStage(opts.data); + return service.create.call(this, uid, { ...opts, data }); + }, + async update(uid, entityId, opts = {}) { + const hasRW = hasReviewWorkflow({ strapi }, uid); + + if (!hasRW) { + return service.update.call(this, uid, entityId, opts); + } + + // Prevents the stage from being set to null + const data = { ...opts.data }; + if (isNull(data[ENTITY_STAGE_ATTRIBUTE])) { + delete data[ENTITY_STAGE_ATTRIBUTE]; + } + + return service.update.call(this, uid, entityId, { ...opts, data }); + }, +}); + +module.exports = () => ({ + decorator, +}); diff --git a/packages/core/admin/ee/server/services/review-workflows/metrics.js b/packages/core/admin/ee/server/services/review-workflows/metrics.js new file mode 100644 index 0000000000..5634648935 --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/metrics.js @@ -0,0 +1,24 @@ +'use strict'; + +const sendDidCreateStage = async () => { + strapi.telemetry.send('didCreateStage', {}); +}; + +const sendDidEditStage = async () => { + strapi.telemetry.send('didEditStage', {}); +}; + +const sendDidDeleteStage = async () => { + strapi.telemetry.send('didDeleteStage', {}); +}; + +const sendDidChangeEntryStage = async () => { + strapi.telemetry.send('didChangeEntryStage', {}); +}; + +module.exports = { + sendDidCreateStage, + sendDidEditStage, + sendDidDeleteStage, + sendDidChangeEntryStage, +}; diff --git a/packages/core/admin/ee/server/services/review-workflows/review-workflows.js b/packages/core/admin/ee/server/services/review-workflows/review-workflows.js new file mode 100644 index 0000000000..77bf2d580d --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/review-workflows.js @@ -0,0 +1,125 @@ +'use strict'; + +const { set, forEach, pipe, map } = require('lodash/fp'); +const { mapAsync } = require('@strapi/utils'); +const { getService } = require('../../utils'); +const { getContentTypeUIDsWithActivatedReviewWorkflows } = require('../../utils/review-workflows'); + +const defaultStages = require('../../constants/default-stages.json'); +const defaultWorkflow = require('../../constants/default-workflow.json'); +const { ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows'); + +const { getDefaultWorkflow } = require('../../utils/review-workflows'); +const { persistTables, removePersistedTablesWithSuffix } = require('../../utils/persisted-tables'); + +async function initDefaultWorkflow({ workflowsService, stagesService, strapi }) { + const wfCount = await workflowsService.count(); + const stagesCount = await stagesService.count(); + + // Check if there is nothing about review-workflow in DB + // If any, the feature has already been initialized with a workflow and stages + if (wfCount === 0 && stagesCount === 0) { + const stages = await stagesService.createMany(defaultStages, { fields: ['id'] }); + const workflow = { + ...defaultWorkflow, + stages: { + connect: stages.map((stage) => stage.id), + }, + }; + + await workflowsService.create(workflow); + // If there is any manually activated RW on content-types, we want to migrate the related entities + await enableReviewWorkflow({ strapi })({ contentTypes: strapi.contentTypes }); + } +} + +function extendReviewWorkflowContentTypes({ strapi }) { + const extendContentType = (contentTypeUID) => { + const setStageAttribute = set(`attributes.${ENTITY_STAGE_ATTRIBUTE}`, { + writable: true, + private: false, + configurable: false, + visible: false, + useJoinTable: true, // We want a join table to persist data when downgrading to CE + type: 'relation', + relation: 'oneToOne', + target: 'admin::workflow-stage', + }); + strapi.container.get('content-types').extend(contentTypeUID, setStageAttribute); + }; + pipe([ + getContentTypeUIDsWithActivatedReviewWorkflows, + // Iterate over UIDs to extend the content-type + forEach(extendContentType), + ])(strapi.contentTypes); +} + +/** + * Enables the review workflow for the given content types. + * @param {Object} strapi - Strapi instance + */ +function enableReviewWorkflow({ strapi }) { + /** + * @param {Array} contentTypes - Content type UIDs to enable the review workflow for. + * @returns {Promise} - Promise that resolves when the review workflow is enabled. + */ + return async ({ contentTypes }) => { + const defaultWorkflow = await getDefaultWorkflow({ strapi }); + // This is possible if this is the first start of EE, there won't be any workflow in DB before bootstrap + if (!defaultWorkflow) { + return; + } + const firstStage = defaultWorkflow.stages[0]; + const stagesService = getService('stages', { strapi }); + + const updateEntitiesStage = async (contentTypeUID) => { + // Update CT entities stage + return stagesService.updateEntitiesStage(contentTypeUID, { + fromStageId: null, + toStageId: firstStage.id, + }); + }; + + return pipe([ + getContentTypeUIDsWithActivatedReviewWorkflows, + // Iterate over UIDs to extend the content-type + (contentTypesUIDs) => mapAsync(contentTypesUIDs, updateEntitiesStage), + ])(contentTypes); + }; +} + +function persistStagesJoinTables({ strapi }) { + return async ({ contentTypes }) => { + const getStageTableToPersist = (contentTypeUID) => { + // Persist the stage join table + const { attributes, tableName } = strapi.db.metadata.get(contentTypeUID); + const joinTableName = attributes[ENTITY_STAGE_ATTRIBUTE].joinTable.name; + return { name: joinTableName, dependsOn: [{ name: tableName }] }; + }; + + const joinTablesToPersist = pipe([ + getContentTypeUIDsWithActivatedReviewWorkflows, + map(getStageTableToPersist), + ])(contentTypes); + + // TODO: Instead of removing all the tables, we should only remove the ones that are not in the joinTablesToPersist + await removePersistedTablesWithSuffix('_strapi_review_workflows_stage_links'); + await persistTables(joinTablesToPersist); + }; +} + +module.exports = ({ strapi }) => { + const workflowsService = getService('workflows', { strapi }); + const stagesService = getService('stages', { strapi }); + + return { + async bootstrap() { + await initDefaultWorkflow({ workflowsService, stagesService, strapi }); + }, + async register() { + extendReviewWorkflowContentTypes({ strapi }); + strapi.hook('strapi::content-types.afterSync').register(enableReviewWorkflow({ strapi })); + strapi.hook('strapi::content-types.afterSync').register(persistStagesJoinTables({ strapi })); + }, + }; +}; diff --git a/packages/core/admin/ee/server/services/review-workflows/stages.js b/packages/core/admin/ee/server/services/review-workflows/stages.js new file mode 100644 index 0000000000..520e1fb6fd --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/stages.js @@ -0,0 +1,267 @@ +'use strict'; + +const { + mapAsync, + errors: { ApplicationError }, +} = require('@strapi/utils'); +const { map } = require('lodash/fp'); + +const { STAGE_MODEL_UID, ENTITY_STAGE_ATTRIBUTE } = require('../../constants/workflows'); +const { getService } = require('../../utils'); +const { getContentTypeUIDsWithActivatedReviewWorkflows } = require('../../utils/review-workflows'); + +module.exports = ({ strapi }) => { + const workflowsService = getService('workflows', { strapi }); + const metrics = getService('review-workflows-metrics', { strapi }); + + return { + find({ workflowId, populate }) { + const params = { + filters: { workflow: workflowId }, + populate, + }; + return strapi.entityService.findMany(STAGE_MODEL_UID, params); + }, + + findById(id, { populate } = {}) { + const params = { + populate, + }; + return strapi.entityService.findOne(STAGE_MODEL_UID, id, params); + }, + + async createMany(stagesList, { fields }) { + const params = { select: fields }; + + const stages = await Promise.all( + stagesList.map((stage) => + strapi.entityService.create(STAGE_MODEL_UID, { data: stage, ...params }) + ) + ); + + metrics.sendDidCreateStage(); + + return stages; + }, + + async update(stageId, stageData) { + const stage = await strapi.entityService.update(STAGE_MODEL_UID, stageId, { + data: stageData, + }); + + metrics.sendDidEditStage(); + + return stage; + }, + + async delete(stageId) { + const stage = await strapi.entityService.delete(STAGE_MODEL_UID, stageId); + + metrics.sendDidDeleteStage(); + + return stage; + }, + + count() { + return strapi.entityService.count(STAGE_MODEL_UID); + }, + + async replaceWorkflowStages(workflowId, stages) { + const workflow = await workflowsService.findById(workflowId, { populate: ['stages'] }); + + const { created, updated, deleted } = getDiffBetweenStages(workflow.stages, stages); + + assertAtLeastOneStageRemain(workflow.stages, { created, deleted }); + + return strapi.db.transaction(async ({ trx }) => { + // Create the new stages + const createdStages = await this.createMany(created, { fields: ['id'] }); + // Put all the newly created stages ids + const createdStagesIds = map('id', createdStages); + const stagesIds = stages.map((stage) => stage.id ?? createdStagesIds.shift()); + const contentTypes = getContentTypeUIDsWithActivatedReviewWorkflows(strapi.contentTypes); + + // Update the workflow stages + await mapAsync(updated, (stage) => this.update(stage.id, stage)); + + // Delete the stages that are not in the new stages list + await mapAsync(deleted, async (stage) => { + // Find the nearest stage in the workflow and newly created stages + // that is not deleted, prioritizing the previous stages + const nearestStage = findNearestMatchingStage( + [...workflow.stages, ...createdStages], + workflow.stages.findIndex((s) => s.id === stage.id), + (targetStage) => { + return !deleted.find((s) => s.id === targetStage.id); + } + ); + + // Assign the new stage to entities that had the deleted stage + await mapAsync(contentTypes, (contentTypeUID) => { + this.updateEntitiesStage(contentTypeUID, { + fromStageId: stage.id, + toStageId: nearestStage.id, + trx, + }); + }); + + return this.delete(stage.id); + }); + + return workflowsService.update(workflowId, { + stages: stagesIds, + }); + }); + }, + + /** + * Update the stage of an entity + * + * @param {object} entityInfo + * @param {number} entityInfo.id - Entity id + * @param {string} entityInfo.modelUID - the content-type of the entity + * @param {number} stageId - The id of the stage to assign to the entity + */ + async updateEntity(entityInfo, stageId) { + const stage = await this.findById(stageId); + + if (!stage) { + throw new ApplicationError(`Selected stage does not exist`); + } + + const entity = await strapi.entityService.update(entityInfo.modelUID, entityInfo.id, { + data: { [ENTITY_STAGE_ATTRIBUTE]: stageId }, + populate: [ENTITY_STAGE_ATTRIBUTE], + }); + + metrics.sendDidChangeEntryStage(); + + return entity; + }, + + /** + * Updates the stage of all entities of a content type that are in a specific stage + * @param {string} contentTypeUID + * @param {number} fromStageId + * @param {number} toStageId + * @param {KnexTransaction} trx + * @returns + */ + async updateEntitiesStage(contentTypeUID, { fromStageId, toStageId, trx = null }) { + const { attributes, tableName } = strapi.db.metadata.get(contentTypeUID); + const joinTable = attributes[ENTITY_STAGE_ATTRIBUTE].joinTable; + const joinColumn = joinTable.joinColumn.name; + const invJoinColumn = joinTable.inverseJoinColumn.name; + + const selectStatement = strapi.db + .getConnection() + .select({ [joinColumn]: 't1.id', [invJoinColumn]: toStageId }) + .from(`${tableName} as t1`) + .leftJoin(`${joinTable.name} as t2`, `t1.id`, `t2.${joinColumn}`) + .where(`t2.${invJoinColumn}`, fromStageId) + .toSQL(); + + // Insert rows for all entries of the content type that do not have a + // default stage + const query = strapi.db + .getConnection(joinTable.name) + .insert( + strapi.db.connection.raw( + `(${joinColumn}, ${invJoinColumn}) ${selectStatement.sql}`, + selectStatement.bindings + ) + ); + + if (trx) { + query.transacting(trx); + } + + return query; + }, + }; +}; + +/** + * Compares two arrays of stages and returns an object indicating the differences. + * + * The function compares the `id` properties of each stage in `sourceStages` and `comparisonStages` to determine if the stage is present in both arrays. + * If a stage with the same `id` is found in both arrays but the `name` property is different, the stage is considered updated. + * If a stage with a particular `id` is only found in `comparisonStages`, it is considered created. + * If a stage with a particular `id` is only found in `sourceStages`, it is considered deleted. + * + * @typedef {{id: Number, name: String, workflow: Number}} Stage + * @typedef {{created: Stage[], updated: Stage[], deleted: Stage[]}} DiffStages + * + * The DiffStages object has three properties: `created`, `updated`, and `deleted`. + * `created` is an array of stages that are in `comparisonStages` but not in `sourceStages`. + * `updated` is an array of stages that have different names in `comparisonStages` and `sourceStages`. + * `deleted` is an array of stages that are in `sourceStages` but not in `comparisonStages`. + * + * @param {Stage[]} sourceStages + * @param {Stage[]} comparisonStages + * @returns { DiffStages } + */ +function getDiffBetweenStages(sourceStages, comparisonStages) { + const result = comparisonStages.reduce( + (acc, stageToCompare) => { + const srcStage = sourceStages.find((stage) => stage.id === stageToCompare.id); + + if (!srcStage) { + acc.created.push(stageToCompare); + } else if (srcStage.name !== stageToCompare.name) { + acc.updated.push(stageToCompare); + } + return acc; + }, + { created: [], updated: [] } + ); + + result.deleted = sourceStages.filter( + (srcStage) => !comparisonStages.some((cmpStage) => cmpStage.id === srcStage.id) + ); + + return result; +} + +/** + * Asserts that at least one stage remains in the workflow after applying deletions and additions. + * + * @param {Array} workflowStages - An array of stages in the current workflow. + * @param {Object} diffStages - An object containing the stages to be deleted and created. + * @param {Array} diffStages.deleted - An array of stages that are planned to be deleted from the workflow. + * @param {Array} diffStages.created - An array of stages that are planned to be created in the workflow. + * + * @throws {ApplicationError} If the number of remaining stages in the workflow after applying deletions and additions is less than 1. + */ +function assertAtLeastOneStageRemain(workflowStages, diffStages) { + const remainingStagesCount = + workflowStages.length - diffStages.deleted.length + diffStages.created.length; + if (remainingStagesCount < 1) { + throw new ApplicationError('At least one stage must remain in the workflow.'); + } +} + +/** + * Find the id of the nearest object in an array that matches a condition. + * Used for searching for the nearest stage that is not deleted. + * Starts by searching the elements before the index, then the remaining elements in the array. + * + * @param {Array} stages + * @param {Number} startIndex the index to start searching from + * @param {Function} condition must evaluate to true for the object to be considered a match + * @returns {Object} stage + */ +function findNearestMatchingStage(stages, startIndex, condition) { + // Start by searching the elements before the startIndex + for (let i = startIndex; i >= 0; i -= 1) { + if (condition(stages[i])) { + return stages[i]; + } + } + + // If no matching element is found before the startIndex, + // search the remaining elements in the array + const remainingArray = stages.slice(startIndex + 1); + const nearestObject = remainingArray.filter(condition)[0]; + return nearestObject; +} diff --git a/packages/core/admin/ee/server/services/review-workflows/workflows.js b/packages/core/admin/ee/server/services/review-workflows/workflows.js new file mode 100644 index 0000000000..9989a5c9e4 --- /dev/null +++ b/packages/core/admin/ee/server/services/review-workflows/workflows.js @@ -0,0 +1,25 @@ +'use strict'; + +const { WORKFLOW_MODEL_UID } = require('../../constants/workflows'); + +module.exports = ({ strapi }) => ({ + find(opts) { + return strapi.entityService.findMany(WORKFLOW_MODEL_UID, opts); + }, + + findById(id, opts) { + return strapi.entityService.findOne(WORKFLOW_MODEL_UID, id, opts); + }, + + create(workflowData) { + return strapi.entityService.create(WORKFLOW_MODEL_UID, { data: workflowData }); + }, + + count() { + return strapi.entityService.count(WORKFLOW_MODEL_UID); + }, + + update(id, workflowData) { + return strapi.entityService.update(WORKFLOW_MODEL_UID, id, { data: workflowData }); + }, +}); diff --git a/packages/core/admin/ee/server/utils/__tests__/persisted-tables.test.js b/packages/core/admin/ee/server/utils/__tests__/persisted-tables.test.js new file mode 100644 index 0000000000..4f67cd87e5 --- /dev/null +++ b/packages/core/admin/ee/server/utils/__tests__/persisted-tables.test.js @@ -0,0 +1,38 @@ +'use strict'; + +const { findTables } = require('../persisted-tables'); + +const strapiMock = { + db: { + dialect: { + schemaInspector: { + getTables: jest.fn(() => []), + }, + }, + }, +}; +describe('Persist table functions', () => { + describe('findTables', () => { + test('should return an empty array if no tables are found', async () => { + strapiMock.db.dialect.schemaInspector.getTables.mockReturnValueOnce([ + 'addresses', + 'not_a_strapi_table', + ]); + const result = await findTables({ strapi: strapiMock }, /^strapi_.*/); + + expect(result).toEqual([]); + }); + test('should return a filtered array of table names', async () => { + strapiMock.db.dialect.schemaInspector.getTables.mockReturnValueOnce([ + 'addresses', + 'strapi_users', + 'strapi_roles', + 'strapi_plugins', + 'not_a_strapi_table', + ]); + const result = await findTables({ strapi: strapiMock }, /^strapi_.*/); + + expect(result).toEqual(['strapi_users', 'strapi_roles', 'strapi_plugins']); + }); + }); +}); diff --git a/packages/core/admin/ee/server/utils/index.js b/packages/core/admin/ee/server/utils/index.js new file mode 100644 index 0000000000..0ab28d2609 --- /dev/null +++ b/packages/core/admin/ee/server/utils/index.js @@ -0,0 +1,8 @@ +'use strict'; + +const getService = (name, { strapi } = { strapi: global.strapi }) => { + return strapi.service(`admin::${name}`); +}; +module.exports = { + getService, +}; diff --git a/packages/core/admin/ee/server/utils/persisted-tables.js b/packages/core/admin/ee/server/utils/persisted-tables.js index f811e9a4b8..3cb231af01 100644 --- a/packages/core/admin/ee/server/utils/persisted-tables.js +++ b/packages/core/admin/ee/server/utils/persisted-tables.js @@ -1,49 +1,146 @@ 'use strict'; +const { differenceWith, isEqual } = require('lodash/fp'); + /** - * Finds all tables in the database that start with a prefix - * @param {string} prefix - * @returns {Array} + * Transform table name to the object format + * @param {Array }>} table + * @returns Array<{ table: string; dependsOn?: Array<{ table: string;}> }> */ -const findTablesThatStartWithPrefix = async (prefix) => { - const tables = await strapi.db.dialect.schemaInspector.getTables(); - return tables.filter((tableName) => tableName.startsWith(prefix)); +const transformTableName = (table) => { + if (typeof table === 'string') { + return { name: table }; + } + return table; }; /** - * Get all reserved table names from the core store - * @returns {Array} + * Finds all tables in the database matching the regular expression + * @param {Object} ctx + * @param {Strapi} ctx.strapi + * @param {RegExp} regex + * @returns {Promise} */ -const getPersistedTables = async () => - (await strapi.store.get({ +async function findTables({ strapi }, regex) { + const tables = await strapi.db.dialect.schemaInspector.getTables(); + return tables.filter((tableName) => regex.test(tableName)); +} + +/** + * Add tables name to the reserved tables in core store + * @param {Object} ctx + * @param {Strapi} ctx.strapi + * @param {Array }>} tableNames + * @return {Promise} + */ +async function addPersistTables({ strapi }, tableNames) { + const persistedTables = await getPersistedTables({ strapi }); + const tables = tableNames.map(transformTableName); + + // Get new tables to be persisted, remove tables if they already were persisted + const notPersistedTableNames = differenceWith(isEqual, tables, persistedTables); + // Remove tables that are going to be changed + const tablesToPersist = differenceWith( + (t1, t2) => t1.name === t2.name, + persistedTables, + notPersistedTableNames + ); + + if (!notPersistedTableNames.length) { + return; + } + + tablesToPersist.push(...notPersistedTableNames); + await strapi.store.set({ type: 'core', key: 'persisted_tables', - })) ?? []; + value: tablesToPersist, + }); +} + +/** + * Remove tables name from the reserved tables in core store + * @param {Object} ctx + * @param {Strapi} ctx.strapi + * @param {Array} tableNames + * @return {Promise} + */ +async function removePersistedTables({ strapi }, tableNames) { + const persistedTables = await getPersistedTables({ strapi }); + + // Get new tables to be persisted, remove tables if they already were persisted + const newPersistedTables = differenceWith( + (t1, t2) => t1.name === t2, + persistedTables, + tableNames + ); + + if (newPersistedTables.length === persistedTables.length) { + return; + } + + await strapi.store.set({ + type: 'core', + key: 'persisted_tables', + value: newPersistedTables, + }); +} + +/** + * Get all reserved table names from the core store + * @param {Object} ctx + * @param {Strapi} ctx.strapi + * @returns {Promise} + */ + +async function getPersistedTables({ strapi }) { + const persistedTables = await strapi.store.get({ + type: 'core', + key: 'persisted_tables', + }); + + return (persistedTables || []).map(transformTableName); +} /** * Add all table names that start with a prefix to the reserved tables in * core store * @param {string} tableNamePrefix + * @return {Promise} */ const persistTablesWithPrefix = async (tableNamePrefix) => { - const persistedTables = await getPersistedTables(); - const tableNames = await findTablesThatStartWithPrefix(tableNamePrefix); - const notReservedTableNames = tableNames.filter((name) => !persistedTables.includes(name)); + const tableNameRegex = new RegExp(`^${tableNamePrefix}.*`); + const tableNames = await findTables({ strapi }, tableNameRegex); - if (!notReservedTableNames.length) { + await addPersistTables({ strapi }, tableNames); +}; + +/** + * Remove all table names that end with a suffix from the reserved tables in core store + * @param {string} tableNameSuffix + * @return {Promise} + */ +const removePersistedTablesWithSuffix = async (tableNameSuffix) => { + const tableNameRegex = new RegExp(`.*${tableNameSuffix}$`); + const tableNames = await findTables({ strapi }, tableNameRegex); + if (!tableNames.length) { return; } + await removePersistedTables({ strapi }, tableNames); +}; - persistedTables.push(...notReservedTableNames); - await strapi.store.set({ - type: 'core', - key: 'persisted_tables', - value: persistedTables, - }); +/** + * Add tables to the reserved tables in core store + * @param {Array }} tables + */ +const persistTables = async (tables) => { + await addPersistTables({ strapi }, tables); }; module.exports = { persistTablesWithPrefix, - findTablesThatStartWithPrefix, + removePersistedTablesWithSuffix, + persistTables, + findTables, }; diff --git a/packages/core/admin/ee/server/utils/review-workflows.js b/packages/core/admin/ee/server/utils/review-workflows.js new file mode 100644 index 0000000000..96e47420e7 --- /dev/null +++ b/packages/core/admin/ee/server/utils/review-workflows.js @@ -0,0 +1,34 @@ +'use strict'; + +const { get, keys, pickBy, pipe } = require('lodash/fp'); +const { WORKFLOW_MODEL_UID } = require('../constants/workflows'); + +/** + * Checks if a content type has review workflows enabled. + * @param {string|Object} contentType - Either the modelUID of the content type, or the content type object. + * @returns {boolean} Whether review workflows are enabled for the specified content type. + */ +function hasReviewWorkflow({ strapi }, contentType) { + if (typeof contentType === 'string') { + // If the input is a string, assume it's the modelUID of the content type and retrieve the corresponding object. + return hasReviewWorkflow({ strapi }, strapi.getModel(contentType)); + } + // Otherwise, assume it's the content type object itself and return its `reviewWorkflows` option if it exists. + return contentType?.options?.reviewWorkflows || false; +} +// TODO To be refactored when multiple workflows are added +const getDefaultWorkflow = async ({ strapi }) => + strapi.query(WORKFLOW_MODEL_UID).findOne({ populate: ['stages'] }); + +const getContentTypeUIDsWithActivatedReviewWorkflows = pipe([ + // Pick only content-types with reviewWorkflows options set to true + pickBy(get('options.reviewWorkflows')), + // Get UIDs + keys, +]); + +module.exports = { + hasReviewWorkflow, + getDefaultWorkflow, + getContentTypeUIDsWithActivatedReviewWorkflows, +}; diff --git a/packages/core/admin/ee/server/validation/review-workflows.js b/packages/core/admin/ee/server/validation/review-workflows.js new file mode 100644 index 0000000000..4f67c8645f --- /dev/null +++ b/packages/core/admin/ee/server/validation/review-workflows.js @@ -0,0 +1,24 @@ +'use strict'; + +const { yup, validateYupSchema } = require('@strapi/utils'); + +const stageObject = yup.object().shape({ + id: yup.number().integer().min(1), + name: yup.string().max(255).required(), +}); + +const validateUpdateStagesSchema = yup.array().of(stageObject).required(); +const validateUpdateStageOnEntity = yup + .object() + .shape({ + id: yup.number().integer().min(1).required(), + }) + .required(); + +module.exports = { + validateUpdateStages: validateYupSchema(validateUpdateStagesSchema, { + strict: false, + stripUnknown: true, + }), + validateUpdateStageOnEntity: validateYupSchema(validateUpdateStageOnEntity), +}; diff --git a/packages/core/admin/jest.config.front.js b/packages/core/admin/jest.config.front.js index ad8186134e..6743ab8aa6 100644 --- a/packages/core/admin/jest.config.front.js +++ b/packages/core/admin/jest.config.front.js @@ -3,4 +3,5 @@ module.exports = { preset: '../../../jest-preset.front.js', collectCoverageFrom: ['/packages/core/admin/admin/**/*.js'], + displayName: 'Core admin', }; diff --git a/packages/core/admin/jest.config.js b/packages/core/admin/jest.config.js index b2889fdc0c..2e9fbe6fdc 100644 --- a/packages/core/admin/jest.config.js +++ b/packages/core/admin/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core admin', }; diff --git a/packages/core/admin/package.json b/packages/core/admin/package.json index f80c4e0431..06475d3c97 100644 --- a/packages/core/admin/package.json +++ b/packages/core/admin/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/admin", - "version": "4.9.2", + "version": "4.10.1", "description": "Strapi Admin", "repository": { "type": "git", @@ -48,15 +48,15 @@ "@casl/ability": "^5.4.3", "@fingerprintjs/fingerprintjs": "3.3.6", "@pmmmwh/react-refresh-webpack-plugin": "0.5.10", - "@strapi/babel-plugin-switch-ee-ce": "4.9.2", - "@strapi/data-transfer": "4.9.2", + "@strapi/babel-plugin-switch-ee-ce": "4.10.1", + "@strapi/data-transfer": "4.10.1", "@strapi/design-system": "1.6.6", - "@strapi/helper-plugin": "4.9.2", + "@strapi/helper-plugin": "4.10.1", "@strapi/icons": "1.6.6", - "@strapi/permissions": "4.9.2", - "@strapi/provider-audit-logs-local": "4.9.2", - "@strapi/typescript-utils": "4.9.2", - "@strapi/utils": "4.9.2", + "@strapi/permissions": "4.10.1", + "@strapi/provider-audit-logs-local": "4.10.1", + "@strapi/typescript-utils": "4.10.1", + "@strapi/utils": "4.10.1", "axios": "1.3.4", "babel-loader": "^9.1.2", "babel-plugin-styled-components": "2.0.2", @@ -117,7 +117,7 @@ "react-error-boundary": "3.1.4", "react-fast-compare": "^3.2.0", "react-helmet": "^6.1.0", - "react-intl": "6.3.2", + "react-intl": "6.4.1", "react-is": "^17.0.2", "react-query": "3.24.3", "react-redux": "8.0.5", @@ -133,7 +133,7 @@ "sift": "16.0.1", "style-loader": "3.3.1", "styled-components": "5.3.3", - "typescript": "4.6.2", + "typescript": "5.0.4", "webpack": "^5.76.0", "webpack-cli": "^5.0.1", "webpack-dev-server": "^4.13.1", diff --git a/packages/core/content-manager/jest.config.js b/packages/core/content-manager/jest.config.js index b2889fdc0c..df7be65883 100644 --- a/packages/core/content-manager/jest.config.js +++ b/packages/core/content-manager/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core content-manager', }; diff --git a/packages/core/content-manager/package.json b/packages/core/content-manager/package.json index fe56acd4eb..0fca35c6c3 100644 --- a/packages/core/content-manager/package.json +++ b/packages/core/content-manager/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/plugin-content-manager", - "version": "4.9.2", + "version": "4.10.1", "description": "A powerful UI to easily manage your data.", "repository": { "type": "git", @@ -26,7 +26,7 @@ }, "dependencies": { "@sindresorhus/slugify": "1.1.0", - "@strapi/utils": "4.9.2", + "@strapi/utils": "4.10.1", "lodash": "4.17.21" }, "engines": { diff --git a/packages/core/content-manager/server/services/__tests__/field-sizes.test.js b/packages/core/content-manager/server/services/__tests__/field-sizes.test.js index 0b4e5d3780..3947634f68 100644 --- a/packages/core/content-manager/server/services/__tests__/field-sizes.test.js +++ b/packages/core/content-manager/server/services/__tests__/field-sizes.test.js @@ -71,7 +71,6 @@ describe('field sizes service', () => { const { setCustomFieldInputSizes, getAllFieldSizes } = createFieldSizesService({ strapi }); setCustomFieldInputSizes(); const fieldSizes = getAllFieldSizes(); - console.log(fieldSizes); expect(fieldSizes).not.toHaveProperty('plugin::mycustomfields.color'); expect(fieldSizes['plugin::mycustomfields.smallColor'].default).toBe(4); diff --git a/packages/core/content-type-builder/admin/src/components/DataManagerProvider/index.js b/packages/core/content-type-builder/admin/src/components/DataManagerProvider/index.js index 4866f709ef..46ca2bce3a 100644 --- a/packages/core/content-type-builder/admin/src/components/DataManagerProvider/index.js +++ b/packages/core/content-type-builder/admin/src/components/DataManagerProvider/index.js @@ -10,7 +10,7 @@ import { useNotification, useStrapiApp, useAutoReloadOverlayBlocker, - useAppInfos, + useAppInfo, useRBACProvider, useGuidedTour, useFetchClient, @@ -74,7 +74,7 @@ const DataManagerProvider = ({ const { getPlugin } = useStrapiApp(); const { apis } = getPlugin(pluginId); - const { autoReload } = useAppInfos(); + const { autoReload } = useAppInfo(); const { formatMessage } = useIntl(); const { trackUsage } = useTracking(); const { refetchPermissions } = useRBACProvider(); diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/contentType/createContentTypeSchema.js b/packages/core/content-type-builder/admin/src/components/FormModal/contentType/createContentTypeSchema.js index 7914e530b7..72032f82fd 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/contentType/createContentTypeSchema.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/contentType/createContentTypeSchema.js @@ -112,6 +112,7 @@ const createContentTypeSchema = ({ .required(errorsTrads.required), draftAndPublish: yup.boolean(), kind: yup.string().oneOf(['singleType', 'collectionType']), + reviewWorkflows: yup.boolean(), }; return yup.object(shape); diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/contentType/form.js b/packages/core/content-type-builder/admin/src/components/FormModal/contentType/form.js index fb39ec732c..e76fc4814a 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/contentType/form.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/contentType/form.js @@ -12,25 +12,42 @@ const nameField = { const forms = { advanced: { default() { + const items = [ + { + intlLabel: { + id: getTrad('contentType.draftAndPublish.label'), + defaultMessage: 'Draft & publish', + }, + description: { + id: getTrad('contentType.draftAndPublish.description'), + defaultMessage: 'Allows writing a draft version of an entry, before it is published', + }, + name: 'draftAndPublish', + type: 'toggle-draft-publish', + validations: {}, + }, + ]; + + if (window.strapi.features.isEnabled(window.strapi.features.REVIEW_WORKFLOWS)) { + items.push({ + intlLabel: { + id: getTrad('contentType.reviewWorkflows.label'), + defaultMessage: 'Review Workflows', + }, + description: { + id: getTrad('contentType.reviewWorkflows.description'), + defaultMessage: 'Allows having content in different review stages', + }, + name: 'reviewWorkflows', + type: 'toggle-review-workflows', + validations: {}, + }); + } + return { sections: [ { - items: [ - { - intlLabel: { - id: getTrad('contentType.draftAndPublish.label'), - defaultMessage: 'Draft & publish', - }, - description: { - id: getTrad('contentType.draftAndPublish.description'), - defaultMessage: - 'Allows writing a draft version of an entry, before it is published', - }, - name: 'draftAndPublish', - type: 'toggle-draft-publish', - validations: {}, - }, - ], + items, }, ], }; diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/index.js b/packages/core/content-type-builder/admin/src/components/FormModal/index.js index 3700c3f24a..e23311f366 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/index.js +++ b/packages/core/content-type-builder/admin/src/components/FormModal/index.js @@ -35,6 +35,7 @@ import useFormModalNavigation from '../../hooks/useFormModalNavigation'; import AllowedTypesSelect from '../AllowedTypesSelect'; import AttributeOptions from '../AttributeOptions'; import DraftAndPublishToggle from '../DraftAndPublishToggle'; +import ReviewWorkflowsToggle from '../ReviewWorkflowsToggle'; import FormModalHeader from '../FormModalHeader'; import FormModalEndActions from '../FormModalEndActions'; import FormModalSubHeader from '../FormModalSubHeader'; @@ -190,6 +191,7 @@ const FormModal = () => { actionType, data: { draftAndPublish: true, + reviewWorkflows: false, }, pluginOptions: {}, }); @@ -197,16 +199,20 @@ const FormModal = () => { // Edit content type if (modalType === 'contentType' && actionType === 'edit') { - const { displayName, draftAndPublish, kind, pluginOptions, pluralName, singularName } = get( - allDataSchema, - [...pathToSchema, 'schema'], - { - displayName: null, - pluginOptions: {}, - singularName: null, - pluralName: null, - } - ); + const { + displayName, + draftAndPublish, + kind, + pluginOptions, + pluralName, + reviewWorkflows, + singularName, + } = get(allDataSchema, [...pathToSchema, 'schema'], { + displayName: null, + pluginOptions: {}, + singularName: null, + pluralName: null, + }); dispatch({ type: SET_DATA_TO_EDIT, @@ -218,6 +224,10 @@ const FormModal = () => { kind, pluginOptions, pluralName, + // because review-workflows is an EE feature the attribute does + // not always exist, but the component prop-types expect a boolean, + // so we have to ensure undefined is casted to false + reviewWorkflows: reviewWorkflows ?? false, singularName, }, }); @@ -915,6 +925,7 @@ const FormModal = () => { 'select-number': SelectNumber, 'select-date': SelectDateType, 'toggle-draft-publish': DraftAndPublishToggle, + 'toggle-review-workflows': ReviewWorkflowsToggle, 'text-plural': PluralName, 'text-singular': SingularName, 'textarea-enum': TextareaEnum, diff --git a/packages/core/content-type-builder/admin/src/components/ReviewWorkflowsToggle/index.js b/packages/core/content-type-builder/admin/src/components/ReviewWorkflowsToggle/index.js new file mode 100644 index 0000000000..e794087187 --- /dev/null +++ b/packages/core/content-type-builder/admin/src/components/ReviewWorkflowsToggle/index.js @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { Checkbox } from '@strapi/design-system'; +import { ConfirmDialog, useTracking } from '@strapi/helper-plugin'; + +import { getTrad } from '../../utils'; + +const ReviewWorkflowsToggle = ({ + description, + disabled, + intlLabel, + isCreating, + name, + onChange, + value, +}) => { + const { trackUsage } = useTracking(); + const { formatMessage } = useIntl(); + const [showWarning, setShowWarning] = useState(false); + const label = intlLabel.id + ? formatMessage( + { id: intlLabel.id, defaultMessage: intlLabel.defaultMessage }, + { ...intlLabel.values } + ) + : name; + + const hint = description + ? formatMessage( + { id: description.id, defaultMessage: description.defaultMessage }, + { ...description.values } + ) + : ''; + + const handleToggle = () => setShowWarning((prev) => !prev); + + const handleConfirm = () => { + onChange({ target: { name, value: false } }); + trackUsage('willDisableWorkflow'); + handleToggle(); + }; + + const handleChange = ({ target: { checked } }) => { + if (!checked && !isCreating) { + handleToggle(); + + return; + } + + if (checked) { + trackUsage('willEnableWorkflow'); + } + + onChange({ target: { name, value: checked } }); + }; + + return ( + <> + + {label} + + + + + ); +}; + +ReviewWorkflowsToggle.defaultProps = { + description: null, + disabled: false, + value: false, +}; + +ReviewWorkflowsToggle.propTypes = { + description: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + values: PropTypes.object, + }), + disabled: PropTypes.bool, + intlLabel: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + values: PropTypes.object, + }).isRequired, + isCreating: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.bool, +}; + +export default ReviewWorkflowsToggle; diff --git a/packages/core/content-type-builder/admin/src/translations/en.json b/packages/core/content-type-builder/admin/src/translations/en.json index 2d548d49b0..7806f7265c 100644 --- a/packages/core/content-type-builder/admin/src/translations/en.json +++ b/packages/core/content-type-builder/admin/src/translations/en.json @@ -54,6 +54,8 @@ "contentType.draftAndPublish.description": "Allows writing a draft version of an entry, before it is published", "contentType.draftAndPublish.label": "Draft & publish", "contentType.kind.change.warning": "You just changed the kind of a content type: API will be reset (routes, controllers, and services will be overwritten).", + "contentType.reviewWorkflows.label": "Review Workflows", + "contentType.reviewWorkflows.description": "Allows having content in different review stages", "error.attributeName.reserved-name": "This name cannot be used in your content type as it might break other functionalities", "error.contentType.pluralName-used": "This value cannot be the same as the singular one", "error.contentType.singularName-used": "This value cannot be the same as the plural one", @@ -180,6 +182,7 @@ "popUpWarning.draft-publish.button.confirm": "Yes, disable", "popUpWarning.draft-publish.message": "If you disable the Draft & publish, your drafts will be deleted.", "popUpWarning.draft-publish.second-message": "Are you sure you want to disable it?", + "popUpWarning.review-workflows.message": "If you disable the review workflows feature, all stage-related information will be removed for this content-type. Are you sure you want to disable it?", "prompt.unsaved": "Are you sure you want to leave? All your modifications will be lost.", "relation.attributeName.placeholder": "Ex: author, category, tag", "relation.manyToMany": "has and belongs to many", diff --git a/packages/core/content-type-builder/jest.config.front.js b/packages/core/content-type-builder/jest.config.front.js index 59252a6fe0..78cbdedd4e 100644 --- a/packages/core/content-type-builder/jest.config.front.js +++ b/packages/core/content-type-builder/jest.config.front.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.front.js', + displayName: 'Core content-type-builder', }; diff --git a/packages/core/content-type-builder/jest.config.js b/packages/core/content-type-builder/jest.config.js index b2889fdc0c..3388d460d0 100644 --- a/packages/core/content-type-builder/jest.config.js +++ b/packages/core/content-type-builder/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core content-type-builder', }; diff --git a/packages/core/content-type-builder/package.json b/packages/core/content-type-builder/package.json index 3d2aaf07bb..8bf4425677 100644 --- a/packages/core/content-type-builder/package.json +++ b/packages/core/content-type-builder/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/plugin-content-type-builder", - "version": "4.9.2", + "version": "4.10.1", "description": "Strapi plugin to create content type", "repository": { "type": "git", @@ -31,11 +31,10 @@ "dependencies": { "@sindresorhus/slugify": "1.1.0", "@strapi/design-system": "1.6.6", - "@strapi/generators": "4.9.2", - "@strapi/helper-plugin": "4.9.2", + "@strapi/generators": "4.10.1", + "@strapi/helper-plugin": "4.10.1", "@strapi/icons": "1.6.6", - "@strapi/strapi": "4.9.2", - "@strapi/utils": "4.9.2", + "@strapi/utils": "4.10.1", "fs-extra": "10.0.0", "immer": "9.0.19", "lodash": "4.17.21", @@ -43,14 +42,14 @@ "prop-types": "^15.7.2", "qs": "6.11.1", "react-helmet": "^6.1.0", - "react-intl": "6.3.2", + "react-intl": "6.4.1", "react-redux": "8.0.5", "redux": "^4.2.1", "reselect": "^4.1.7", "yup": "^0.32.9" }, "devDependencies": { - "@strapi/admin": "4.9.2", + "@strapi/admin": "4.10.1", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", "history": "^4.9.0", diff --git a/packages/core/content-type-builder/server/controllers/validation/content-type.js b/packages/core/content-type-builder/server/controllers/validation/content-type.js index 2a8c4918ac..9931997147 100644 --- a/packages/core/content-type-builder/server/controllers/validation/content-type.js +++ b/packages/core/content-type-builder/server/controllers/validation/content-type.js @@ -72,7 +72,13 @@ const createContentTypeSchema = (data, { isEdition = false } = {}) => { return yup .object({ - contentType: contentTypeSchema.required().noUnknown(), + // FIXME .noUnknown(false) will strip off the unwanted properties without throwing an error + // Why not having .noUnknown() ? Because we want to be able to add options relatable to EE features + // without having any reference to them in CE. + // Why not handle an "options" object in the content-type ? The admin panel needs lots of rework + // to be able to send this options object instead of top-level attributes. + // @nathan-pichon 20/02/2023 + contentType: contentTypeSchema.required().noUnknown(false), components: nestedComponentSchema, }) .noUnknown(); diff --git a/packages/core/content-type-builder/server/controllers/validation/model-schema.js b/packages/core/content-type-builder/server/controllers/validation/model-schema.js index 5820081ccc..04f54ba15c 100644 --- a/packages/core/content-type-builder/server/controllers/validation/model-schema.js +++ b/packages/core/content-type-builder/server/controllers/validation/model-schema.js @@ -13,9 +13,11 @@ const createSchema = (types, relations, { modelType } = {}) => { const shape = { description: yup.string(), draftAndPublish: yup.boolean(), + options: yup.object(), pluginOptions: yup.object(), collectionName: yup.string().nullable().test(isValidCollectionName), attributes: createAttributesValidator({ types, relations, modelType }), + reviewWorkflows: yup.boolean(), }; if (modelType === modelTypes.CONTENT_TYPE) { diff --git a/packages/core/content-type-builder/server/services/content-types.js b/packages/core/content-type-builder/server/services/content-types.js index 45e31812fd..4a32226598 100644 --- a/packages/core/content-type-builder/server/services/content-types.js +++ b/packages/core/content-type-builder/server/services/content-types.js @@ -35,18 +35,18 @@ const getRestrictRelationsTo = (contentType = {}) => { * @param {Object} contentType */ const formatContentType = (contentType) => { - const { uid, kind, modelName, plugin, collectionName, info, options } = contentType; + const { uid, kind, modelName, plugin, collectionName, info } = contentType; return { uid, plugin, apiID: modelName, schema: { + ...contentTypesUtils.getOptions(contentType), displayName: info.displayName, singularName: info.singularName, pluralName: info.pluralName, description: _.get(info, 'description', ''), - draftAndPublish: contentTypesUtils.hasDraftAndPublish({ options }), pluginOptions: contentType.pluginOptions, kind: kind || 'collectionType', collectionName, diff --git a/packages/core/content-type-builder/server/services/schema-builder/content-type-builder.js b/packages/core/content-type-builder/server/services/schema-builder/content-type-builder.js index f8af7acaa1..3aeea6345b 100644 --- a/packages/core/content-type-builder/server/services/schema-builder/content-type-builder.js +++ b/packages/core/content-type-builder/server/services/schema-builder/content-type-builder.js @@ -101,14 +101,17 @@ module.exports = function createComponentBuilder() { contentType .setUID(uid) .set('kind', infos.kind || typeKinds.COLLECTION_TYPE) - .set('collectionName', nameToCollectionName(infos.pluralName)) + .set('collectionName', infos.collectionName || nameToCollectionName(infos.pluralName)) .set('info', { singularName: infos.singularName, pluralName: infos.pluralName, displayName: infos.displayName, description: infos.description, }) - .set('options', { draftAndPublish: infos.draftAndPublish || false }) + .set('options', { + ...(infos.options ?? {}), + draftAndPublish: infos.draftAndPublish || false, + }) .set('pluginOptions', infos.pluginOptions) .set('config', infos.config) .setAttributes(this.convertAttributes(infos.attributes)); @@ -227,7 +230,10 @@ module.exports = function createComponentBuilder() { .set('kind', infos.kind || contentType.schema.kind) .set(['info', 'displayName'], infos.displayName) .set(['info', 'description'], infos.description) - .set(['options', 'draftAndPublish'], infos.draftAndPublish || false) + .set('options', { + ...(infos.options ?? {}), + draftAndPublish: infos.draftAndPublish || false, + }) .set('pluginOptions', infos.pluginOptions) .setAttributes(this.convertAttributes(newAttributes)); diff --git a/packages/core/data-transfer/jest.config.js b/packages/core/data-transfer/jest.config.js index a12bbcffeb..d0e49168ab 100644 --- a/packages/core/data-transfer/jest.config.js +++ b/packages/core/data-transfer/jest.config.js @@ -6,4 +6,5 @@ module.exports = { transform: { '^.+\\.ts$': ['@swc/jest'], }, + displayName: 'Core data-transfer', }; diff --git a/packages/core/data-transfer/package.json b/packages/core/data-transfer/package.json index ce753d63aa..2d431974df 100644 --- a/packages/core/data-transfer/package.json +++ b/packages/core/data-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/data-transfer", - "version": "4.9.2", + "version": "4.10.1", "description": "Data transfer capabilities for Strapi", "keywords": [ "strapi", @@ -40,8 +40,8 @@ "./dist" ], "dependencies": { - "@strapi/logger": "4.9.2", - "@strapi/strapi": "4.9.2", + "@strapi/logger": "4.10.1", + "@strapi/strapi": "4.10.1", "chalk": "4.1.2", "fs-extra": "10.0.0", "lodash": "4.17.21", @@ -50,8 +50,7 @@ "stream-json": "1.7.4", "tar": "6.1.13", "tar-stream": "2.2.0", - "uuid": "9.0.0", - "ws": "8.11.0" + "ws": "8.13.0" }, "devDependencies": { "@tsconfig/node16": "1.0.3", @@ -64,12 +63,11 @@ "@types/stream-json": "1.7.3", "@types/tar": "6.1.4", "@types/tar-stream": "2.2.2", - "@types/uuid": "9.0.0", "@types/ws": "^8.5.4", "knex": "2.4.0", "koa": "2.13.4", "rimraf": "3.0.2", - "typescript": "4.6.2" + "typescript": "5.0.4" }, "engines": { "node": ">=14.19.1 <=18.x.x", diff --git a/packages/core/data-transfer/src/engine/index.ts b/packages/core/data-transfer/src/engine/index.ts index 4ab7aaf028..261c3e3915 100644 --- a/packages/core/data-transfer/src/engine/index.ts +++ b/packages/core/data-transfer/src/engine/index.ts @@ -727,6 +727,9 @@ class TransferEngine< async transferAssets(): Promise { const stage: TransferStage = 'assets'; + if (this.shouldSkipStage(stage)) { + return; + } const source = await this.sourceProvider.createAssetsReadStream?.(); const destination = await this.destinationProvider.createAssetsWriteStream?.(); diff --git a/packages/core/data-transfer/src/strapi/providers/local-destination/index.ts b/packages/core/data-transfer/src/strapi/providers/local-destination/index.ts index f8a5dc0e50..a5555dc3aa 100644 --- a/packages/core/data-transfer/src/strapi/providers/local-destination/index.ts +++ b/packages/core/data-transfer/src/strapi/providers/local-destination/index.ts @@ -156,10 +156,16 @@ class LocalStrapiDestinationProvider implements IDestinationProvider { `uploads_backup_${Date.now()}` ); - await fse.move(assetsDirectory, backupDirectory); - await fse.mkdir(assetsDirectory); - // Create a .gitkeep file to ensure the directory is not empty - await fse.outputFile(path.join(assetsDirectory, '.gitkeep'), ''); + try { + await fse.move(assetsDirectory, backupDirectory); + await fse.mkdir(assetsDirectory); + // Create a .gitkeep file to ensure the directory is not empty + await fse.outputFile(path.join(assetsDirectory, '.gitkeep'), ''); + } catch (err) { + throw new ProviderTransferError( + 'The backup folder for the assets could not be created inside the public folder. Please ensure Strapi has write permissions on the public directory' + ); + } return new Writable({ objectMode: true, diff --git a/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts b/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts index 804be60887..13fef83cde 100644 --- a/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts +++ b/packages/core/data-transfer/src/strapi/providers/remote-destination/index.ts @@ -1,5 +1,5 @@ import { WebSocket } from 'ws'; -import { v4 } from 'uuid'; +import { randomUUID } from 'crypto'; import { Writable } from 'stream'; import { once } from 'lodash/fp'; @@ -336,7 +336,7 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider { hasStarted = true; - const assetID = v4(); + const assetID = randomUUID(); const { filename, filepath, stats, stream } = asset; try { diff --git a/packages/core/data-transfer/src/strapi/providers/utils.ts b/packages/core/data-transfer/src/strapi/providers/utils.ts index 3ad88f1763..7fa744c2c8 100644 --- a/packages/core/data-transfer/src/strapi/providers/utils.ts +++ b/packages/core/data-transfer/src/strapi/providers/utils.ts @@ -1,4 +1,4 @@ -import { v4 } from 'uuid'; +import { randomUUID } from 'crypto'; import { RawData, WebSocket } from 'ws'; import type { client, server } from '../../../types/remote/protocol'; @@ -28,7 +28,7 @@ export const createDispatcher = (ws: WebSocket) => { } return new Promise((resolve, reject) => { - const uuid = v4(); + const uuid = randomUUID(); const payload = { ...message, uuid }; if (options.attachTransfer) { diff --git a/packages/core/database/jest.config.js b/packages/core/database/jest.config.js index b2889fdc0c..bc1ae914de 100644 --- a/packages/core/database/jest.config.js +++ b/packages/core/database/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core database', }; diff --git a/packages/core/database/lib/entity-manager/entity-repository.js b/packages/core/database/lib/entity-manager/entity-repository.js index 4ebcd3b7c9..6779c2528d 100644 --- a/packages/core/database/lib/entity-manager/entity-repository.js +++ b/packages/core/database/lib/entity-manager/entity-repository.js @@ -96,8 +96,15 @@ const createRepository = (uid, db) => { return db.entityManager.attachRelations(uid, id, data); }, - updateRelations(id, data) { - return db.entityManager.updateRelations(uid, id, data); + async updateRelations(id, data) { + const trx = await db.transaction(); + try { + await db.entityManager.updateRelations(uid, id, data, { transaction: trx.get() }); + return trx.commit(); + } catch (e) { + await trx.rollback(); + throw e; + } }, deleteRelations(id) { diff --git a/packages/core/database/lib/entity-manager/regular-relations.js b/packages/core/database/lib/entity-manager/regular-relations.js index 33f780330d..1f90714b1e 100644 --- a/packages/core/database/lib/entity-manager/regular-relations.js +++ b/packages/core/database/lib/entity-manager/regular-relations.js @@ -152,7 +152,7 @@ const deleteRelations = async ({ .transacting(trx) .execute(); done = batchToDelete.length < batchSize; - lastId = batchToDelete[batchToDelete.length - 1]?.id; + lastId = batchToDelete[batchToDelete.length - 1]?.id || 0; const batchIds = map(inverseJoinColumn.name, batchToDelete); diff --git a/packages/core/database/lib/metadata/relations.js b/packages/core/database/lib/metadata/relations.js index dbafdb9f45..ee662b2ec6 100644 --- a/packages/core/database/lib/metadata/relations.js +++ b/packages/core/database/lib/metadata/relations.js @@ -244,6 +244,16 @@ const createMorphToMany = (attributeName, attribute, meta, metadata) => { name: `${joinTableName}_fk`, columns: [joinColumnName], }, + { + name: `${joinTableName}_order_index`, + columns: ['order'], + type: null, + }, + { + name: `${joinTableName}_id_column_index`, + columns: [idColumnName], + type: null, + }, ], foreignKeys: [ { diff --git a/packages/core/database/lib/schema/diff.js b/packages/core/database/lib/schema/diff.js index 96f7018621..50bf280d31 100644 --- a/packages/core/database/lib/schema/diff.js +++ b/packages/core/database/lib/schema/diff.js @@ -344,6 +344,13 @@ module.exports = (db) => { } } + const parsePersistedTable = (persistedTable) => { + if (typeof persistedTable === 'string') { + return persistedTable; + } + return persistedTable.name; + }; + const persistedTables = helpers.hasTable(srcSchema, 'strapi_core_store_settings') ? (await strapi.store.get({ type: 'core', @@ -351,12 +358,22 @@ module.exports = (db) => { })) ?? [] : []; + const reservedTables = [...RESERVED_TABLE_NAMES, ...persistedTables.map(parsePersistedTable)]; + for (const srcTable of srcSchema.tables) { - if ( - !helpers.hasTable(destSchema, srcTable.name) && - ![...RESERVED_TABLE_NAMES, ...persistedTables].includes(srcTable.name) - ) { - removedTables.push(srcTable); + if (!helpers.hasTable(destSchema, srcTable.name) && !reservedTables.includes(srcTable.name)) { + const dependencies = persistedTables + .filter((table) => { + const dependsOn = table?.dependsOn; + if (_.isNil(dependsOn)) return; + // FIXME: The array parse should not be necessary + return _.toArray(dependsOn).some((table) => table.name === srcTable.name); + }) + .map((dependsOnTable) => { + return srcSchema.tables.find((srcTable) => srcTable.name === dependsOnTable.name); + }); + + removedTables.push(srcTable, ...dependencies); } } diff --git a/packages/core/database/package.json b/packages/core/database/package.json index b0502dbc39..79a60c1190 100644 --- a/packages/core/database/package.json +++ b/packages/core/database/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/database", - "version": "4.9.2", + "version": "4.10.1", "description": "Strapi's database layer", "homepage": "https://strapi.io", "bugs": { diff --git a/packages/core/email/jest.config.front.js b/packages/core/email/jest.config.front.js index 59252a6fe0..bfbef4117d 100644 --- a/packages/core/email/jest.config.front.js +++ b/packages/core/email/jest.config.front.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.front.js', + displayName: 'Core email', }; diff --git a/packages/core/email/package.json b/packages/core/email/package.json index 714fc371a0..84731c836c 100644 --- a/packages/core/email/package.json +++ b/packages/core/email/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/plugin-email", - "version": "4.9.2", + "version": "4.10.1", "description": "Easily configure your Strapi application to send emails.", "repository": { "type": "git", @@ -29,15 +29,15 @@ "dependencies": { "@strapi/design-system": "1.6.6", "@strapi/icons": "1.6.6", - "@strapi/provider-email-sendmail": "4.9.2", - "@strapi/utils": "4.9.2", + "@strapi/provider-email-sendmail": "4.10.1", + "@strapi/utils": "4.10.1", "lodash": "4.17.21", "prop-types": "^15.7.2", - "react-intl": "6.3.2", + "react-intl": "6.4.1", "yup": "^0.32.9" }, "devDependencies": { - "@strapi/helper-plugin": "4.9.2", + "@strapi/helper-plugin": "4.10.1", "@testing-library/react": "12.1.4", "msw": "1.0.0", "react": "^17.0.2", diff --git a/packages/core/helper-plugin/jest.config.front.js b/packages/core/helper-plugin/jest.config.front.js index e5c8490758..7cf5eeee20 100644 --- a/packages/core/helper-plugin/jest.config.front.js +++ b/packages/core/helper-plugin/jest.config.front.js @@ -1,4 +1,5 @@ module.exports = { preset: '../../../jest-preset.front.js', collectCoverageFrom: ['/packages/core/helper-plugin/src/**/*.js'], + displayName: 'Helper plugin', }; diff --git a/packages/core/helper-plugin/package.json b/packages/core/helper-plugin/package.json index 6b806f9c93..abb822b694 100644 --- a/packages/core/helper-plugin/package.json +++ b/packages/core/helper-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/helper-plugin", - "version": "4.9.2", + "version": "4.10.1", "description": "Helper for Strapi plugins development", "repository": { "type": "git", @@ -52,7 +52,7 @@ "prop-types": "^15.7.2", "qs": "6.11.1", "react-helmet": "^6.1.0", - "react-intl": "6.3.2", + "react-intl": "6.4.1", "react-select": "5.7.0" }, "devDependencies": { @@ -66,6 +66,7 @@ "@strapi/icons": "1.6.6", "@testing-library/react": "12.1.4", "@testing-library/react-hooks": "8.0.1", + "@testing-library/user-event": "14.4.3", "browserslist-to-esbuild": "1.2.0", "cross-env": "^7.0.3", "esbuild-loader": "^2.21.0", @@ -73,10 +74,9 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-router-dom": "5.3.4", - "react-test-renderer": "^17.0.2", "rimraf": "3.0.2", "styled-components": "5.3.3", - "typescript": "4.6.2", + "typescript": "5.0.4", "webpack": "^5.76.0", "webpack-cli": "^5.0.1" }, diff --git a/packages/core/helper-plugin/src/components/CheckPagePermissions/index.js b/packages/core/helper-plugin/src/components/CheckPagePermissions/index.js index 5191bc49bc..c2b21ab266 100644 --- a/packages/core/helper-plugin/src/components/CheckPagePermissions/index.js +++ b/packages/core/helper-plugin/src/components/CheckPagePermissions/index.js @@ -1,8 +1,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { Redirect } from 'react-router-dom'; import PropTypes from 'prop-types'; -import useNotification from '../../hooks/useNotification'; -import useRBACProvider from '../../hooks/useRBACProvider'; +import { useNotification } from '../../features/Notifications'; +import { useRBACProvider } from '../../features/RBAC'; import hasPermissions from '../../utils/hasPermissions'; import LoadingIndicatorPage from '../LoadingIndicatorPage'; diff --git a/packages/core/helper-plugin/src/components/CheckPermissions/index.js b/packages/core/helper-plugin/src/components/CheckPermissions/index.js index 5efa328317..e62ed28183 100644 --- a/packages/core/helper-plugin/src/components/CheckPermissions/index.js +++ b/packages/core/helper-plugin/src/components/CheckPermissions/index.js @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import useNotification from '../../hooks/useNotification'; +import { useNotification } from '../../features/Notifications'; import hasPermissions from '../../utils/hasPermissions'; -import useRBACProvider from '../../hooks/useRBACProvider'; +import { useRBACProvider } from '../../features/RBAC'; // NOTE: this component is very similar to the CheckPagePermissions // except that it does not handle redirections nor loading state diff --git a/packages/core/helper-plugin/src/components/DynamicTable/index.js b/packages/core/helper-plugin/src/components/DynamicTable/index.js index c77a66f8e4..2f96166f8e 100644 --- a/packages/core/helper-plugin/src/components/DynamicTable/index.js +++ b/packages/core/helper-plugin/src/components/DynamicTable/index.js @@ -5,7 +5,7 @@ import { useIntl } from 'react-intl'; import { Trash } from '@strapi/icons'; import styled from 'styled-components'; import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; +import { useTracking } from '../../features/Tracking'; import ConfirmDialog from '../ConfirmDialog'; import EmptyBodyTable from '../EmptyBodyTable'; import TableHead from './TableHead'; diff --git a/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/FilterPopoverURLQuery.stories.mdx b/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/FilterPopoverURLQuery.stories.mdx index f44e62da47..d6e0f427b7 100644 --- a/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/FilterPopoverURLQuery.stories.mdx +++ b/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/FilterPopoverURLQuery.stories.mdx @@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from 'react'; import { Meta, ArgsTable, Canvas, Story } from '@storybook/addon-docs'; import { Button, Box, Main, Flex } from '@strapi/design-system'; import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; +import { useTracking } from '../../features/Tracking'; import FilterListURLQuery from '../FilterListURLQuery'; import FilterPopoverURLQuery from './index'; diff --git a/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/index.js b/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/index.js index ba99d267dc..6f06154629 100644 --- a/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/index.js +++ b/packages/core/helper-plugin/src/components/FilterPopoverURLQuery/index.js @@ -10,7 +10,7 @@ import { Button, Flex, Box, Popover, FocusTrap, Select, Option } from '@strapi/d import { Plus } from '@strapi/icons'; import { useIntl } from 'react-intl'; import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; +import { useTracking } from '../../features/Tracking'; import DefaultInputs from './Inputs'; import getFilterList from './utils/getFilterList'; diff --git a/packages/core/helper-plugin/src/components/InjectionZone/useInjectionZone.js b/packages/core/helper-plugin/src/components/InjectionZone/useInjectionZone.js index 980d7f00c3..3ebfa2cac5 100644 --- a/packages/core/helper-plugin/src/components/InjectionZone/useInjectionZone.js +++ b/packages/core/helper-plugin/src/components/InjectionZone/useInjectionZone.js @@ -1,4 +1,4 @@ -import useStrapiApp from '../../hooks/useStrapiApp'; +import { useStrapiApp } from '../../features/StrapiApp'; const useInjectionZone = (area) => { const { getPlugin } = useStrapiApp(); diff --git a/packages/core/helper-plugin/src/components/PageSizeURLQuery/index.js b/packages/core/helper-plugin/src/components/PageSizeURLQuery/index.js index b8ed1fe109..527ce4559f 100644 --- a/packages/core/helper-plugin/src/components/PageSizeURLQuery/index.js +++ b/packages/core/helper-plugin/src/components/PageSizeURLQuery/index.js @@ -9,7 +9,7 @@ import { useIntl } from 'react-intl'; import { Box, Flex, Select, Option, Typography } from '@strapi/design-system'; import PropTypes from 'prop-types'; import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; +import { useTracking } from '../../features/Tracking'; const PageSizeURLQuery = ({ trackedEvent, options, defaultValue }) => { const { formatMessage } = useIntl(); diff --git a/packages/core/helper-plugin/src/components/PageSizeURLQuery/tests/index.test.js b/packages/core/helper-plugin/src/components/PageSizeURLQuery/tests/index.test.js index f4078632db..592720e638 100644 --- a/packages/core/helper-plugin/src/components/PageSizeURLQuery/tests/index.test.js +++ b/packages/core/helper-plugin/src/components/PageSizeURLQuery/tests/index.test.js @@ -12,8 +12,10 @@ import { createMemoryHistory } from 'history'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; import PageSizeURLQuery from '../index'; -jest.mock('../../../hooks/useTracking', () => () => ({ - trackUsage: jest.fn(), +jest.mock('../../../features/Tracking', () => ({ + useTracking: () => ({ + trackUsage: jest.fn(), + }), })); const messages = { diff --git a/packages/core/helper-plugin/src/components/ReactSelect/utils/getSelectStyles.js b/packages/core/helper-plugin/src/components/ReactSelect/utils/getSelectStyles.js index 51ee94f470..c418345f2d 100644 --- a/packages/core/helper-plugin/src/components/ReactSelect/utils/getSelectStyles.js +++ b/packages/core/helper-plugin/src/components/ReactSelect/utils/getSelectStyles.js @@ -75,6 +75,7 @@ const getSelectStyles = (theme, error) => { return { ...base, + color: theme.colors.neutral800, lineHeight: theme.spaces[5], backgroundColor, borderRadius: theme.borderRadius, @@ -103,6 +104,7 @@ const getSelectStyles = (theme, error) => { }, valueContainer: (base) => ({ ...base, + cursor: 'pointer', padding: 0, paddingLeft: theme.spaces[4], marginLeft: 0, diff --git a/packages/core/helper-plugin/src/components/SearchURLQuery/index.js b/packages/core/helper-plugin/src/components/SearchURLQuery/index.js index f36ee96c8d..54f79c2b42 100644 --- a/packages/core/helper-plugin/src/components/SearchURLQuery/index.js +++ b/packages/core/helper-plugin/src/components/SearchURLQuery/index.js @@ -4,7 +4,7 @@ import { useIntl } from 'react-intl'; import { Search as SearchIcon } from '@strapi/icons'; import { Searchbar, SearchForm, IconButton, Icon } from '@strapi/design-system'; import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; +import { useTracking } from '../../features/Tracking'; const SearchURLQuery = ({ label, placeholder, trackedEvent, trackedEventDetails }) => { const wrapperRef = useRef(null); diff --git a/packages/core/helper-plugin/src/components/SearchURLQuery/tests/index.test.js b/packages/core/helper-plugin/src/components/SearchURLQuery/tests/index.test.js index 4467449cb9..f0772fbd22 100644 --- a/packages/core/helper-plugin/src/components/SearchURLQuery/tests/index.test.js +++ b/packages/core/helper-plugin/src/components/SearchURLQuery/tests/index.test.js @@ -13,8 +13,10 @@ import { ThemeProvider, lightTheme } from '@strapi/design-system'; import SearchURLQuery from '../index'; const trackUsage = jest.fn(); -jest.mock('../../../hooks/useTracking', () => () => ({ - trackUsage, +jest.mock('../../../features/Tracking', () => ({ + useTracking: () => ({ + trackUsage, + }), })); const makeApp = (history, trackedEvent) => ( diff --git a/packages/core/helper-plugin/src/contexts/AppInfosContext.js b/packages/core/helper-plugin/src/contexts/AppInfosContext.js deleted file mode 100644 index 3592cdc778..0000000000 --- a/packages/core/helper-plugin/src/contexts/AppInfosContext.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * AppInfosContext - * - * - */ - -import { createContext } from 'react'; - -const AppInfosContext = createContext(); - -export default AppInfosContext; diff --git a/packages/core/helper-plugin/src/contexts/AutoReloadOverlayBockerContext.js b/packages/core/helper-plugin/src/contexts/AutoReloadOverlayBockerContext.js deleted file mode 100644 index 2574a881a4..0000000000 --- a/packages/core/helper-plugin/src/contexts/AutoReloadOverlayBockerContext.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * AutoReloadOverlayBlocker - * - * - */ - -import { createContext } from 'react'; - -const AutoReloadOverlayBlocker = createContext(); - -export default AutoReloadOverlayBlocker; diff --git a/packages/core/helper-plugin/src/contexts/CustomFieldsContext.js b/packages/core/helper-plugin/src/contexts/CustomFieldsContext.js deleted file mode 100644 index 07fd26c870..0000000000 --- a/packages/core/helper-plugin/src/contexts/CustomFieldsContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * CustomFieldsContext - * - */ - -import { createContext } from 'react'; - -const CustomFieldsContext = createContext(); - -export default CustomFieldsContext; diff --git a/packages/core/helper-plugin/src/contexts/GuidedTourContext.js b/packages/core/helper-plugin/src/contexts/GuidedTourContext.js deleted file mode 100644 index d2779fc744..0000000000 --- a/packages/core/helper-plugin/src/contexts/GuidedTourContext.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * GuidedTourContext - * - * - */ - -import { createContext } from 'react'; - -const GuidedTourContext = createContext(); - -export default GuidedTourContext; diff --git a/packages/core/helper-plugin/src/contexts/LibraryContext.js b/packages/core/helper-plugin/src/contexts/LibraryContext.js deleted file mode 100644 index 3e3ac21186..0000000000 --- a/packages/core/helper-plugin/src/contexts/LibraryContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * LibraryContext - * - */ - -import { createContext } from 'react'; - -const LibraryContext = createContext(); - -export default LibraryContext; diff --git a/packages/core/helper-plugin/src/contexts/NotificationsContext.js b/packages/core/helper-plugin/src/contexts/NotificationsContext.js deleted file mode 100644 index b9918c225b..0000000000 --- a/packages/core/helper-plugin/src/contexts/NotificationsContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * NotificationsContext - * - */ - -import { createContext } from 'react'; - -const NotificationsContext = createContext(); - -export default NotificationsContext; diff --git a/packages/core/helper-plugin/src/contexts/OverlayBlockerContext.js b/packages/core/helper-plugin/src/contexts/OverlayBlockerContext.js deleted file mode 100644 index d6c40743d5..0000000000 --- a/packages/core/helper-plugin/src/contexts/OverlayBlockerContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * OverlayBlockerContext - * - */ - -import { createContext } from 'react'; - -const OverlayBlockerContext = createContext(); - -export default OverlayBlockerContext; diff --git a/packages/core/helper-plugin/src/contexts/RBACProviderContext.js b/packages/core/helper-plugin/src/contexts/RBACProviderContext.js deleted file mode 100644 index 9077188541..0000000000 --- a/packages/core/helper-plugin/src/contexts/RBACProviderContext.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * RBACProviderContext - * - * - */ - -import { createContext } from 'react'; - -const RBACProviderContext = createContext(); - -export default RBACProviderContext; diff --git a/packages/core/helper-plugin/src/contexts/StrapiAppContext.js b/packages/core/helper-plugin/src/contexts/StrapiAppContext.js deleted file mode 100644 index 93a7aa5a0c..0000000000 --- a/packages/core/helper-plugin/src/contexts/StrapiAppContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * StrapiAppContext - * - */ - -import { createContext } from 'react'; - -const StrapiAppContext = createContext(); - -export default StrapiAppContext; diff --git a/packages/core/helper-plugin/src/contexts/TrackingContext.js b/packages/core/helper-plugin/src/contexts/TrackingContext.js deleted file mode 100644 index 7b7bb04022..0000000000 --- a/packages/core/helper-plugin/src/contexts/TrackingContext.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * - * TrackingContext - * - */ - -import { createContext } from 'react'; - -const TrackingContext = createContext(); - -export default TrackingContext; diff --git a/packages/core/helper-plugin/src/features/AppInfo.js b/packages/core/helper-plugin/src/features/AppInfo.js new file mode 100644 index 0000000000..ef010de231 --- /dev/null +++ b/packages/core/helper-plugin/src/features/AppInfo.js @@ -0,0 +1,163 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * TODO: review this type, do we need to it all? + */ + +/** + * @preserve + * @typedef {Object} AppInfoContextValue + * @property {boolean | undefined} autoReload + * @property {boolean | undefined} communityEdition + * @property {string | undefined} currentEnvironment + * @property {Record} dependencies + * @property {string | null} latestStrapiReleaseTag + * @property {string | undefined} nodeVersion + * @property {string | undefined} projectId + * @property {(name: string) => void} setUserDisplayName + * @property {boolean} shouldUpdateStrapi + * @property {string | undefined} strapiVersion + * @property {boolean | undefined} useYarn + * @property {string} userDisplayName + * @property {string | null} userId + * + */ + +/** + * @preserve + * @type {React.Context} + */ +const AppInfoContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const AppInfoProvider = ({ + children, + autoReload, + communityEdition, + currentEnvironment, + dependencies, + latestStrapiReleaseTag, + nodeVersion, + projectId, + setUserDisplayName, + shouldUpdateStrapi, + strapiVersion, + useYarn, + userDisplayName, + userId, +}) => { + /** + * @type {AppInfoContextValue} + */ + const contextValue = React.useMemo( + () => ({ + autoReload, + communityEdition, + currentEnvironment, + dependencies, + latestStrapiReleaseTag, + nodeVersion, + projectId, + setUserDisplayName, + shouldUpdateStrapi, + strapiVersion, + useYarn, + userDisplayName, + userId, + }), + [ + autoReload, + communityEdition, + currentEnvironment, + dependencies, + latestStrapiReleaseTag, + nodeVersion, + projectId, + setUserDisplayName, + shouldUpdateStrapi, + strapiVersion, + useYarn, + userDisplayName, + userId, + ] + ); + + return {children}; +}; + +AppInfoProvider.defaultProps = { + autoReload: undefined, + communityEdition: undefined, + currentEnvironment: undefined, + dependencies: undefined, + latestStrapiReleaseTag: undefined, + nodeVersion: undefined, + projectId: undefined, + strapiVersion: undefined, + useYarn: undefined, + userId: null, +}; + +AppInfoProvider.propTypes = { + children: PropTypes.node.isRequired, + autoReload: PropTypes.bool, + communityEdition: PropTypes.bool, + currentEnvironment: PropTypes.string, + dependencies: PropTypes.object, + latestStrapiReleaseTag: PropTypes.string, + nodeVersion: PropTypes.string, + projectId: PropTypes.string, + setUserDisplayName: PropTypes.func.isRequired, + shouldUpdateStrapi: PropTypes.bool.isRequired, + strapiVersion: PropTypes.string, + useYarn: PropTypes.bool, + userDisplayName: PropTypes.string.isRequired, + userId: PropTypes.string, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {AppInfoContextValue} + */ +const useAppInfo = () => React.useContext(AppInfoContext); + +/** + * TODO: rename these to remove the plural in next major version + */ +/** + * @preserve + * @deprecated use useAppInfo instead + */ +const useAppInfos = useAppInfo; +/** + * @preserve + * @deprecated use AppInfoProvider instead + */ +const AppInfosProvider = AppInfoProvider; +/** + * @preserve + * @deprecated use AppInfoContext instead + */ +const AppInfosContext = AppInfoContext; + +export { + AppInfoProvider, + AppInfoContext, + useAppInfo, + useAppInfos, + AppInfosProvider, + AppInfosContext, +}; diff --git a/packages/core/helper-plugin/src/features/AutoReloadOverlayBlocker.js b/packages/core/helper-plugin/src/features/AutoReloadOverlayBlocker.js new file mode 100644 index 0000000000..249744dcf8 --- /dev/null +++ b/packages/core/helper-plugin/src/features/AutoReloadOverlayBlocker.js @@ -0,0 +1,248 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; +import styled, { keyframes } from 'styled-components'; +import { Flex, Box, Typography } from '@strapi/design-system'; +import { Refresh, Clock } from '@strapi/icons'; +import { useIntl } from 'react-intl'; +import { createPortal } from 'react-dom'; +import { Link } from '@strapi/design-system/v2'; +import pxToRem from '../utils/pxToRem'; + +/** + * TODO: realistically a lot of this logic is isolated to the `core/admin` package. + * However, we want to expose the `useAutoReloadOverlayBlocker` hook to the plugins. + * + * Therefore, in V5 we should move this logic back to the `core/admin` package & export + * the hook from that package and re-export here. For now, let's keep it all together + * because it's easier to diagnose and we're not using a million refs because we don't + * understand what's going on. + */ + +/** + * @preserve + * @typedef {Object} AutoReloadOverlayBlockerConfig + * @property {string | undefined} title + * @property {string | undefined} description + * @property {'reload' | 'time' | undefined} icon + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} AutoReloadOverlayBlockerContextValue + * @property {(config: AutoReloadOverlayBlockerConfig) => void} lockAppWithAutoreload + * @property {() => void} unlockAppWithAutoreload + */ + +/** + * @preserve + * @type {React.Context} + */ + +const AutoReloadOverlayBlockerContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const MAX_ELAPSED_TIME = 30 * 1000; + +const AutoReloadOverlayBlockerProvider = ({ children }) => { + const [isOpen, setIsOpen] = React.useState(false); + /** + * @type {[AutoReloadOverlayBlockerConfig, React.Dispatch>]} + */ + const [config, setConfig] = React.useState(undefined); + const [failed, setFailed] = React.useState(false); + + const lockAppWithAutoreload = React.useCallback((config = undefined) => { + setIsOpen(true); + setConfig(config); + }, []); + + const unlockAppWithAutoreload = React.useCallback(() => { + setIsOpen(false); + setConfig(undefined); + }, []); + + // eslint-disable-next-line consistent-return + React.useEffect(() => { + if (isOpen) { + const timeout = setTimeout(() => { + setFailed(true); + }, MAX_ELAPSED_TIME); + + return () => { + clearTimeout(timeout); + }; + } + }, [isOpen]); + + let displayedIcon = config?.icon || 'reload'; + + let description = { + id: config?.description || 'components.OverlayBlocker.description', + defaultMessage: + "You're using a feature that needs the server to restart. Please wait until the server is up.", + }; + + let title = { + id: config?.title || 'components.OverlayBlocker.title', + defaultMessage: 'Waiting for restart', + }; + + if (failed) { + displayedIcon = 'time'; + + description = { + id: 'components.OverlayBlocker.description.serverError', + defaultMessage: 'The server should have restarted, please check your logs in the terminal.', + }; + + title = { + id: 'components.OverlayBlocker.title.serverError', + defaultMessage: 'The restart is taking longer than expected', + }; + } + + const autoReloadValue = React.useMemo( + () => ({ + lockAppWithAutoreload, + unlockAppWithAutoreload, + }), + [lockAppWithAutoreload, unlockAppWithAutoreload] + ); + + return ( + + + {children} + + ); +}; + +AutoReloadOverlayBlockerProvider.propTypes = { + children: PropTypes.element.isRequired, +}; + +const Blocker = ({ displayedIcon = 'reload', description, title, isOpen }) => { + const { formatMessage } = useIntl(); + + // eslint-disable-next-line no-undef + return isOpen && globalThis?.document?.body + ? createPortal( + + + + {formatMessage(title)} + + + {formatMessage(description)} + + + {displayedIcon === 'reload' && ( + + + + )} + {displayedIcon === 'time' && ( + + + + )} + + + {formatMessage({ + id: 'global.documentation', + defaultMessage: 'Read the documentation', + })} + + + , + // eslint-disable-next-line no-undef + globalThis.document.body + ) + : null; +}; + +Blocker.propTypes = { + displayedIcon: PropTypes.string.isRequired, + description: PropTypes.object.isRequired, + isOpen: PropTypes.bool.isRequired, + title: PropTypes.object.isRequired, +}; + +const rotation = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } + `; + +const LoaderReload = styled(Refresh)` + animation: ${rotation} 1s infinite linear; +`; + +const Overlay = styled(Flex)` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + /* TODO: set this up in the theme for consistence z-index values */ + z-index: 1140; + padding-top: ${pxToRem(160)}; + + & > * { + position: relative; + z-index: 1; + } + + &:before { + content: ''; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: ${({ theme }) => theme.colors.neutral0}; + opacity: 0.9; + } +`; + +const IconBox = styled(Box)` + border-radius: 50%; + svg { + > path { + fill: ${({ theme }) => theme.colors.primary600} !important; + } + } +`; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {AutoReloadOverlayBlockerContextValue} + */ + +const useAutoReloadOverlayBlocker = () => React.useContext(AutoReloadOverlayBlockerContext); + +export { + AutoReloadOverlayBlockerContext, + AutoReloadOverlayBlockerProvider, + useAutoReloadOverlayBlocker, +}; diff --git a/packages/core/helper-plugin/src/features/CustomFields.js b/packages/core/helper-plugin/src/features/CustomFields.js new file mode 100644 index 0000000000..842a5a2407 --- /dev/null +++ b/packages/core/helper-plugin/src/features/CustomFields.js @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; + +/** + * @preserve + * @typedef {Object} CustomField + * @property {string} name - The name of the custom field + * @property {string} pluginId - The plugin id of the custom field + * @property {string} type - The type of the custom field + * @property {import('react-intl').MessageDescriptor} intlLabel + * @property {import('react-intl').MessageDescriptor} intlDescription + * @property {unknown} components + * @property {unknown} options + * @property {import('react').ComponentType} icon + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} CustomFieldsContextValue + * @property {(uid: string) => CustomField | undefined} get + * @property {() => Record} getAll + */ + +/** + * @preserve + * @type {React.Context} + */ +const CustomFieldsContext = React.createContext({ + get() {}, + getAll() {}, +}); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const CustomFieldsProvider = ({ children, customFields }) => { + /** + * @type {CustomFieldsContextValue['get']} + */ + const get = customFields.get.bind(customFields); + + /** + * @type {CustomFieldsContextValue['getAll']} + */ + const getAll = customFields.getAll.bind(customFields); + + /** + * @type {CustomFieldsContextValue} + */ + const value = React.useMemo(() => ({ get, getAll }), [get, getAll]); + + return {children}; +}; + +CustomFieldsProvider.propTypes = { + children: PropTypes.node.isRequired, + customFields: PropTypes.shape({ + get: PropTypes.func.isRequired, + getAll: PropTypes.func.isRequired, + }).isRequired, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {CustomFieldsContextValue} + */ +const useCustomFields = () => React.useContext(CustomFieldsContext); + +export { CustomFieldsProvider, useCustomFields, CustomFieldsContext }; diff --git a/packages/core/helper-plugin/src/hooks/useCustomFields/useCustomFields.stories.mdx b/packages/core/helper-plugin/src/features/CustomFields.stories.mdx similarity index 100% rename from packages/core/helper-plugin/src/hooks/useCustomFields/useCustomFields.stories.mdx rename to packages/core/helper-plugin/src/features/CustomFields.stories.mdx diff --git a/packages/core/helper-plugin/src/features/GuidedTour.js b/packages/core/helper-plugin/src/features/GuidedTour.js new file mode 100644 index 0000000000..3f90e367fa --- /dev/null +++ b/packages/core/helper-plugin/src/features/GuidedTour.js @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; + +/** + * TODO: whats the value in having this in the `helper-plugin`? is it actually + * used externally. I doubt it. + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * ------------------x-----------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} GuidedTourContextValue + * @property {string} currentStep + * @property {Object} guidedTourState + * @property {boolean} isGuidedTourVisible + * @property {boolean} isSkipped + * @property {(step: string) => void} setCurrentStep + * @property {(isVisible: boolean) => void} setGuidedTourVisibility + * @property {(isSkipped: boolean) => void} setSkipped + * @property {(step: string, state: { create: boolean; success: boolean }) => void} setStepState + * @property {(section: string) => void} startSection + */ + +/** + * @preserve + * @type {React.Context} + */ +const GuidedTourContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const GuidedTourProvider = ({ + children, + currentStep, + guidedTourState, + isGuidedTourVisible, + isSkipped, + setCurrentStep, + setGuidedTourVisibility, + setSkipped, + setStepState, + startSection, +}) => { + const value = React.useMemo( + () => ({ + currentStep, + guidedTourState, + isGuidedTourVisible, + isSkipped, + setCurrentStep, + setGuidedTourVisibility, + setSkipped, + setStepState, + startSection, + }), + [ + currentStep, + guidedTourState, + isGuidedTourVisible, + isSkipped, + setCurrentStep, + setGuidedTourVisibility, + setSkipped, + setStepState, + startSection, + ] + ); + + return {children}; +}; + +GuidedTourProvider.defaultProps = { + currentStep: null, + isGuidedTourVisible: false, +}; + +GuidedTourProvider.propTypes = { + children: PropTypes.node.isRequired, + currentStep: PropTypes.string, + guidedTourState: PropTypes.objectOf( + PropTypes.shape({ + create: PropTypes.bool, + success: PropTypes.bool, + }) + ).isRequired, + isGuidedTourVisible: PropTypes.bool, + isSkipped: PropTypes.bool.isRequired, + setCurrentStep: PropTypes.func.isRequired, + setGuidedTourVisibility: PropTypes.func.isRequired, + setSkipped: PropTypes.func.isRequired, + setStepState: PropTypes.func.isRequired, + startSection: PropTypes.func.isRequired, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {GuidedTourContextValue} + */ +const useGuidedTour = () => React.useContext(GuidedTourContext); + +export { GuidedTourProvider, useGuidedTour, GuidedTourContext }; diff --git a/packages/core/helper-plugin/src/providers/GuidedTourProvider/GuidedTourProvider.stories.mdx b/packages/core/helper-plugin/src/features/GuidedTour.stories.mdx similarity index 100% rename from packages/core/helper-plugin/src/providers/GuidedTourProvider/GuidedTourProvider.stories.mdx rename to packages/core/helper-plugin/src/features/GuidedTour.stories.mdx diff --git a/packages/core/helper-plugin/src/features/Library.js b/packages/core/helper-plugin/src/features/Library.js new file mode 100644 index 0000000000..dc49610010 --- /dev/null +++ b/packages/core/helper-plugin/src/features/Library.js @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} LibraryContextValue + * @property {Record} fields + * @property {Record} components + */ + +/** + * @preserve + * @type {React.Context} LibraryContext + */ +const LibraryContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const LibraryProvider = ({ children, fields, components }) => { + const value = React.useMemo(() => ({ fields, components }), [fields, components]); + + return {children}; +}; + +LibraryProvider.propTypes = { + children: PropTypes.node.isRequired, + components: PropTypes.object.isRequired, + fields: PropTypes.object.isRequired, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {LibraryContextValue} + */ +const useLibrary = () => React.useContext(LibraryContext); + +export { LibraryProvider, useLibrary, LibraryContext }; diff --git a/packages/core/helper-plugin/src/features/Notifications.js b/packages/core/helper-plugin/src/features/Notifications.js new file mode 100644 index 0000000000..2bee5a8af4 --- /dev/null +++ b/packages/core/helper-plugin/src/features/Notifications.js @@ -0,0 +1,302 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import { Alert, Flex } from '@strapi/design-system'; +import { Link } from '@strapi/design-system/v2'; +import { useCallbackRef } from '../hooks/useCallbackRef'; + +/** + * TODO: realistically a lot of this logic is isolated to the `core/admin` package. + * However, we want to expose the `useNotification` hook to the plugins. + * + * Therefore, in V5 we should move this logic back to the `core/admin` package & export + * the hook from that package and re-export here. For now, let's keep it all together + * because it's easier to diagnose and we're not using a million refs because we don't + * understand what's going on. + */ + +/** + * @preserve + * @typedef {Object} NotificationLink + * @property {string | import('react-intl').MessageDescriptor} label + * @property {string | undefined} target + * @property {string} url + */ + +/** + * @preserve + * @typedef {Object} NotificationConfig + * @property {boolean | undefined} blockTransition + * @property {NotificationLink} link + * @property {string | import('react-intl').MessageDescriptor | undefined} message + * @property {() => void | undefined} onClose + * @property {number | undefined} timeout + * @property {string | import('react-intl').MessageDescriptor | undefined} title + * @property {"info" | "warning" | "softWarning" | "success" | undefined} type + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} NotificationsContextValue + * @property {(config: NotificationConfig) => void} toggleNotification – Toggles a notification, wrapped in `useCallback` for a stable identity. + */ + +/** + * @type {React.Context} + */ +const NotificationsContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const NotificationsProvider = ({ children }) => { + const notificationIdRef = React.useRef(0); + const [notifications, setNotifications] = React.useState([]); + + const toggleNotification = React.useCallback( + ({ type, message, link, timeout, blockTransition, onClose, title }) => { + setNotifications((s) => [ + ...s, + { + id: notificationIdRef.current++, + type, + message, + link, + timeout, + blockTransition, + onClose, + title, + }, + ]); + }, + [] + ); + + const clearNotification = React.useCallback((id) => { + setNotifications((s) => s.filter((n) => n.id !== id)); + }, []); + + const value = React.useMemo(() => ({ toggleNotification }), [toggleNotification]); + + return ( + + + {notifications.map((notification) => { + return ( + + ); + })} + + {children} + + ); +}; + +NotificationsProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +const Notification = ({ + id, + clearNotification, + message, + link, + type, + onClose, + timeout, + blockTransition, + title, +}) => { + const { formatMessage } = useIntl(); + + /** + * Chances are `onClose` won't be classed as stabilised, + * so we use `useCallbackRef` to avoid make it stable. + */ + const onCloseCallback = useCallbackRef(onClose); + + const handleClose = React.useCallback(() => { + onCloseCallback(); + + clearNotification(id); + }, [clearNotification, id, onCloseCallback]); + + // eslint-disable-next-line consistent-return + React.useEffect(() => { + if (!blockTransition) { + const timeoutReference = setTimeout(() => { + handleClose(); + }, timeout); + + return () => { + clearTimeout(timeoutReference); + }; + } + }, [blockTransition, handleClose, timeout]); + + let variant; + let alertTitle; + + if (type === 'info') { + variant = 'default'; + alertTitle = formatMessage({ + id: 'notification.default.title', + defaultMessage: 'Information:', + }); + } else if (type === 'warning') { + // TODO: type should be renamed to danger in the future, but it might introduce changes if done now + variant = 'danger'; + alertTitle = formatMessage({ + id: 'notification.warning.title', + defaultMessage: 'Warning:', + }); + } else if (type === 'softWarning') { + // TODO: type should be renamed to just warning in the future + variant = 'warning'; + alertTitle = formatMessage({ + id: 'notification.warning.title', + defaultMessage: 'Warning:', + }); + } else { + variant = 'success'; + alertTitle = formatMessage({ + id: 'notification.success.title', + defaultMessage: 'Success:', + }); + } + + if (title) { + alertTitle = + typeof title === 'string' + ? title + : formatMessage( + { + id: title?.id || title, + defaultMessage: title?.defaultMessage || title?.id || title, + }, + title?.values + ); + } + + return ( + + {formatMessage({ + id: link.label?.id || link.label, + defaultMessage: link.label?.defaultMessage || link.label?.id || link.label, + })} + + ) : undefined + } + onClose={handleClose} + closeLabel="Close" + title={alertTitle} + variant={variant} + > + {formatMessage( + { + id: message?.id || message, + defaultMessage: message?.defaultMessage || message?.id || message, + }, + message?.values + )} + + ); +}; + +Notification.defaultProps = { + blockTransition: false, + link: undefined, + onClose: undefined, + message: { + id: 'notification.success.saved', + defaultMessage: 'Saved', + }, + timeout: 2500, + title: undefined, + type: 'success', +}; + +Notification.propTypes = { + id: PropTypes.number.isRequired, + clearNotification: PropTypes.func.isRequired, + message: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string, + values: PropTypes.object, + }), + ]), + link: PropTypes.shape({ + target: PropTypes.string, + url: PropTypes.string.isRequired, + label: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string, + values: PropTypes.object, + }), + ]).isRequired, + }), + type: PropTypes.string, + onClose: PropTypes.func, + timeout: PropTypes.number, + blockTransition: PropTypes.bool, + title: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string, + values: PropTypes.object, + }), + ]), +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @description Returns an object to interact with the notification + * system. The callbacks are wrapped in `useCallback` for a stable + * identity. + * + * @returns {NotificationsContextValue} + * + * @example + * ```tsx + * import { useNotification } from '@strapi/helper-plugin'; + * + * const MyComponent = () => { + * const { toggleNotification } = useNotification(); + * + * return ; + */ +const useNotification = () => React.useContext(NotificationsContext).toggleNotification; + +export { NotificationsProvider, useNotification, NotificationsContext }; diff --git a/packages/core/helper-plugin/src/hooks/useNotification/useNotification.stories.mdx b/packages/core/helper-plugin/src/features/Notifications.stories.mdx similarity index 100% rename from packages/core/helper-plugin/src/hooks/useNotification/useNotification.stories.mdx rename to packages/core/helper-plugin/src/features/Notifications.stories.mdx diff --git a/packages/core/helper-plugin/src/features/OverlayBlocker.js b/packages/core/helper-plugin/src/features/OverlayBlocker.js new file mode 100644 index 0000000000..3032ff0ba3 --- /dev/null +++ b/packages/core/helper-plugin/src/features/OverlayBlocker.js @@ -0,0 +1,77 @@ +/* eslint-disable no-undef */ +import * as React from 'react'; + +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { Box } from '@strapi/design-system'; +import { createPortal } from 'react-dom'; + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} OverlayBlockerContextValue + * @property {() => void} lockApp + * @property {() => void} unlockApp + */ + +/** + * @preserve + * @type {React.Context} + */ +const OverlayBlockerContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const OverlayBlockerProvider = ({ children }) => { + const [isOpen, setIsOpen] = React.useState(false); + + const lockApp = React.useCallback(() => { + setIsOpen(true); + }, []); + + const unlockApp = React.useCallback(() => { + setIsOpen(false); + }, []); + + const contextValue = React.useMemo(() => ({ lockApp, unlockApp }), [lockApp, unlockApp]); + + return ( + + {children} + {isOpen && globalThis?.document?.body + ? createPortal(, globalThis.document.body) + : null} + + ); +}; + +OverlayBlockerProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +const Overlay = styled(Box)` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + /* TODO: set this up in the theme for consistence z-index values */ + z-index: 1140; +`; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {OverlayBlockerContextValue} + */ +const useOverlayBlocker = () => React.useContext(OverlayBlockerContext); + +export { OverlayBlockerProvider, useOverlayBlocker, OverlayBlockerContext }; diff --git a/packages/core/helper-plugin/src/features/RBAC.js b/packages/core/helper-plugin/src/features/RBAC.js new file mode 100644 index 0000000000..324256c430 --- /dev/null +++ b/packages/core/helper-plugin/src/features/RBAC.js @@ -0,0 +1,60 @@ +import * as React from 'react'; + +/** + * @preserve + * @typedef {Object} Permission + * @property {string} action + * @property {unknown[]} conditions + * @property {number} id + * @property {Record} properties + * @property {string} subject + */ + +/** + * @preserve + * @typedef {import('react-query').QueryObserverBaseResult['refetch']} RefetchPermissionsFn + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {Object} RBACContextValue + * @property {Permission[]} allPermissions – The permissions of the current user. + * @property {RefetchPermissionsFn} refetchPermissions + */ + +/** + * @preserve + * @type {React.Context} + */ +const RBACContext = React.createContext(); + +/** + * @deprecated Use RBACContext instead. + */ +const RBACProviderContext = RBACContext; + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +/** + * TODO: in another iteration where we tackle the RBAC hooks // system to consolidate it all into one hook. + */ + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {RBACContextValue} + */ +const useRBAC = () => React.useContext(RBACContext); + +const useRBACProvider = useRBAC; + +export { RBACContext, RBACProviderContext, useRBACProvider }; diff --git a/packages/core/helper-plugin/src/features/StrapiApp.js b/packages/core/helper-plugin/src/features/StrapiApp.js new file mode 100644 index 0000000000..413d6ad896 --- /dev/null +++ b/packages/core/helper-plugin/src/features/StrapiApp.js @@ -0,0 +1,106 @@ +import * as React from 'react'; + +import PropTypes from 'prop-types'; + +/** + * @preserve + * @typedef {Object} MenuItem + * @property {string} to + * @property {React.ComponentType} icon + * @property {import('react-intl').MessageDescriptor} intlLabel + * @property {string[]} [permissions] + * @property {React.ComponentType} [Component] + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * TODO: we need to define the type of a `Plugin` & the hook functions + */ + +/** + * @preserve + * @typedef {Object} StrapiAppContextValue + * @property {(pluginId: string) => unknown | undefined} getPlugin + * @property {MenuItem[]} menu + * @property {Record} plugins + * @property {(hookName: string) => Promise} runHookParallel + * @property {(hookName: string) => Promise} runHookWaterfall + * @property {(hookName: string) => Promise} runHookSeries + * @property {Record} settings + */ + +/** + * @preserve + * @type {React.Context} + */ +const StrapiAppContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const StrapiAppProvider = ({ + children, + getPlugin, + menu, + plugins, + runHookParallel, + runHookSeries, + runHookWaterfall, + settings, +}) => { + /** + * @type {StrapiAppContextValue} + */ + const contextValue = React.useMemo( + () => ({ + getPlugin, + menu, + plugins, + runHookParallel, + runHookSeries, + runHookWaterfall, + settings, + }), + [getPlugin, menu, plugins, runHookParallel, runHookSeries, runHookWaterfall, settings] + ); + + return {children}; +}; + +StrapiAppProvider.propTypes = { + children: PropTypes.node.isRequired, + getPlugin: PropTypes.func.isRequired, + menu: PropTypes.arrayOf( + PropTypes.shape({ + to: PropTypes.string.isRequired, + icon: PropTypes.func.isRequired, + intlLabel: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + }).isRequired, + permissions: PropTypes.array, + Component: PropTypes.func, + }) + ).isRequired, + plugins: PropTypes.object.isRequired, + runHookParallel: PropTypes.func.isRequired, + runHookWaterfall: PropTypes.func.isRequired, + runHookSeries: PropTypes.func.isRequired, + settings: PropTypes.object.isRequired, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @returns {StrapiAppContextValue} + */ +const useStrapiApp = () => React.useContext(StrapiAppContext); + +export { StrapiAppProvider, useStrapiApp, StrapiAppContext }; diff --git a/packages/core/helper-plugin/src/features/Tracking.js b/packages/core/helper-plugin/src/features/Tracking.js new file mode 100644 index 0000000000..a60b74cac6 --- /dev/null +++ b/packages/core/helper-plugin/src/features/Tracking.js @@ -0,0 +1,139 @@ +import * as React from 'react'; + +import axios from 'axios'; +import PropTypes from 'prop-types'; + +import { useAppInfo } from './AppInfo'; + +/** + * @preserve + * @typedef {Object} TelemetryProperties + * @property {boolean} useTypescriptOnServer + * @property {boolean} useTypescriptOnAdmin + * @property {boolean} isHostedOnStrapiCloud + * @property {number} numberOfAllContentTypes + * @property {number} numberOfComponents + * @property {number} numberOfDynamicZones + */ + +/** + * @preserve + * @typedef {Object} TrackingContextValue + * @property {string | boolean} uuid + * @property {string | undefined} deviceId + * @property {TelemetryProperties} telemetryProperties + */ + +/* ------------------------------------------------------------------------------------------------- + * Context + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @type {React.Context} + */ +const TrackingContext = React.createContext(); + +/* ------------------------------------------------------------------------------------------------- + * Provider + * -----------------------------------------------------------------------------------------------*/ + +const TrackingProvider = ({ value, children }) => { + const memoizedValue = React.useMemo(() => value, [value]); + + return {children}; +}; + +TrackingProvider.propTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.shape({ + uuid: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + deviceId: PropTypes.string, + telemetryProperties: PropTypes.object, + }), +}; + +TrackingProvider.defaultProps = { + value: { + deviceId: undefined, + uuid: false, + telemetryProperties: undefined, + }, +}; + +/* ------------------------------------------------------------------------------------------------- + * Hook + * -----------------------------------------------------------------------------------------------*/ + +/** + * @preserve + * @typedef {(event: string, properties: Record) => Promise} TrackUsageFn + */ + +/** + * @preserve + * @description Used to send amplitude events to the Strapi Tracking hub. + * + * @returns {{trackUsage: TrackUsageFn}} + * + * @example + * ```tsx + * import { useTracking } from '@strapi/helper-plugin'; + * + * const MyComponent = () => { + * const { trackUsage } = useTracking(); + * + * const handleClick = () => { + * trackUsage('my-event', { myProperty: 'myValue' }); + * } + * + * return + * } + * ``` + */ +const useTracking = () => { + const { uuid, telemetryProperties, deviceId } = React.useContext(TrackingContext); + const appInfo = useAppInfo(); + const userId = appInfo?.userId; + + /** + * @type {TrackUsageFn} + */ + const trackUsage = React.useCallback( + async (event, properties) => { + try { + if (uuid && !window.strapi.telemetryDisabled) { + const res = await axios.post( + 'https://analytics.strapi.io/api/v2/track', + { + event, + userId, + deviceId, + eventProperties: { ...properties }, + userProperties: {}, + groupProperties: { + ...telemetryProperties, + projectId: uuid, + projectType: window.strapi.projectType, + }, + }, + { + headers: { 'Content-Type': 'application/json' }, + } + ); + + return res; + } + } catch (err) { + // Silence is golden + } + + return null; + }, + [deviceId, telemetryProperties, userId, uuid] + ); + + return { trackUsage }; +}; + +export { TrackingProvider, useTracking, TrackingContext }; diff --git a/packages/core/helper-plugin/src/features/tests/Notifications.test.js b/packages/core/helper-plugin/src/features/tests/Notifications.test.js new file mode 100644 index 0000000000..a54ea10bb4 --- /dev/null +++ b/packages/core/helper-plugin/src/features/tests/Notifications.test.js @@ -0,0 +1,174 @@ +import React from 'react'; +import { render as renderRTL } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { lightTheme, ThemeProvider } from '@strapi/design-system'; +import { NotificationsProvider, useNotification } from '../Notifications'; + +const defaultNotificationConfig = { + type: 'success', + message: 'test', +}; + +// eslint-disable-next-line react/prop-types +const Component = (notificationConfig) => { + const toggleNotification = useNotification(); + + const handleClick = () => { + toggleNotification({ ...defaultNotificationConfig, ...notificationConfig }); + }; + + return ( + + ); +}; + +const render = (props) => ({ + user: userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }), + ...renderRTL(, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }), +}); + +describe('Notifications', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('rendering', () => { + it('should by default it should render a basic notification and only one when triggered, it should disappear after 2500ms', async () => { + const { user, getByRole, getByText, queryByText, rerender } = render(); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByText(/test/)).toBeInTheDocument(); + + jest.advanceTimersByTime(3000); + + expect(queryByText(/test/)).not.toBeInTheDocument(); + + rerender(); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByText('react-intl test title')).toBeInTheDocument(); + + jest.advanceTimersByTime(3000); + + expect(queryByText('react-intl test title')).not.toBeInTheDocument(); + }); + + it('should render a link when passed as a prop', async () => { + const { user, getByRole } = render({ + link: { + label: 'test-link', + url: 'https://google.com', + }, + }); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByRole('link', { name: 'test-link' })).toBeInTheDocument(); + }); + + it('should render a message when passed as a prop', async () => { + const { user, getByRole, getByText, rerender } = render({ + message: 'test-message', + }); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByText('test-message')).toBeInTheDocument(); + + rerender(); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByText('react-intl test message')).toBeInTheDocument(); + }); + }); + + describe('props', () => { + it('when `toggleNotification` is called with `blockTransition = true` it should not remove the notification automatically', async () => { + const { user, getByRole, queryAllByText } = render({ blockTransition: true }); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(queryAllByText(/test/)).toHaveLength(1); + + jest.advanceTimersByTime(3000); + + expect(queryAllByText(/test/)).toHaveLength(1); + + await user.click(getByRole('button', { name: 'Close' })); + + expect(queryAllByText(/test/)).toHaveLength(0); + }); + + it('should call onClose when the notification is closed', async () => { + const onClose = jest.fn(); + const { user, getByRole, queryAllByText } = render({ onClose }); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(queryAllByText(/test/)).toHaveLength(1); + + await user.click(getByRole('button', { name: 'Close' })); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should allow you to set a custom timeout for the notification', async () => { + const { user, getByRole, queryByText, getByText } = render({ timeout: 1000 }); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(getByText(/test/)).toBeInTheDocument(); + + jest.advanceTimersByTime(1001); + + expect(queryByText(/test/)).not.toBeInTheDocument(); + }); + }); + + it('should still remove existing notificaitons when a new one is triggered', async () => { + const { user, getByRole, queryAllByText } = render(); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(queryAllByText(/test/)).toHaveLength(1); + + jest.advanceTimersByTime(2000); + + await user.click(getByRole('button', { name: 'Trigger Notification' })); + + expect(queryAllByText(/test/)).toHaveLength(2); + + jest.advanceTimersByTime(1000); + + expect(queryAllByText(/test/)).toHaveLength(1); + }); +}); diff --git a/packages/core/helper-plugin/src/features/tests/Tracking.test.js b/packages/core/helper-plugin/src/features/tests/Tracking.test.js new file mode 100644 index 0000000000..55e118aaea --- /dev/null +++ b/packages/core/helper-plugin/src/features/tests/Tracking.test.js @@ -0,0 +1,112 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import axios from 'axios'; + +import { useAppInfo } from '../AppInfo'; +import { useTracking, TrackingProvider } from '../Tracking'; + +jest.mock('../AppInfo'); + +jest.mock('axios', () => ({ + ...jest.requireActual('axios'), + post: jest.fn().mockResolvedValue({ + success: true, + }), +})); + +const setup = (props) => + renderHook(() => useTracking(), { + wrapper: ({ children, ...restProps }) => ( + + {children} + + ), + initialProps: props, + }); + +describe('useTracking', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call axios.post with all attributes by default when calling trackUsage()', async () => { + useAppInfo.mockReturnValue({ + currentEnvironment: 'testing', + userId: 'someTestUserId', + }); + + const { result } = setup(); + + const res = await result.current.trackUsage('event', { trackingProperty: true }); + + expect(axios.post).toBeCalledWith( + 'https://analytics.strapi.io/api/v2/track', + { + userId: 'someTestUserId', + deviceId: 'someTestDeviceId', + event: 'event', + eventProperties: { + trackingProperty: true, + }, + groupProperties: { + nestedProperty: true, + projectId: '1', + projectType: 'Community', + }, + userProperties: {}, + }, + { + headers: { 'Content-Type': 'application/json' }, + } + ); + + expect(res).toMatchInlineSnapshot(` + { + "success": true, + } + `); + }); + + it('should not fire axios.post if strapi.telemetryDisabled is true', async () => { + window.strapi.telemetryDisabled = true; + + const { result } = setup(); + + await result.current.trackUsage(); + + expect(axios.post).not.toBeCalled(); + + window.strapi.telemetryDisabled = false; + }); + + it('should not track if there is no uuid set in the context', async () => { + const { result } = setup({ + uuid: null, + }); + + await result.current.trackUsage(); + + expect(axios.post).not.toBeCalled(); + }); + + it('should fail gracefully if the request does not work', async () => { + axios.post = jest.fn().mockRejectedValueOnce({}); + + const { result } = setup(); + + const res = await result.current.trackUsage('event', { trackingProperty: true }); + + expect(axios.post).toHaveBeenCalled(); + expect(res).toEqual(null); + expect(result.current.trackUsage).not.toThrow(); + }); +}); diff --git a/packages/core/helper-plugin/src/hooks/useAppInfos/__mocks__/index.js b/packages/core/helper-plugin/src/hooks/useAppInfos/__mocks__/index.js deleted file mode 100644 index 273c3e2a1c..0000000000 --- a/packages/core/helper-plugin/src/hooks/useAppInfos/__mocks__/index.js +++ /dev/null @@ -1,3 +0,0 @@ -const useAppInfosMock = jest.fn().mockReturnValue({}); - -export default useAppInfosMock; diff --git a/packages/core/helper-plugin/src/hooks/useAppInfos/index.js b/packages/core/helper-plugin/src/hooks/useAppInfos/index.js deleted file mode 100644 index 4f646b76e5..0000000000 --- a/packages/core/helper-plugin/src/hooks/useAppInfos/index.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * - * useAppInfos - * - */ - -import { useContext } from 'react'; -import AppInfosContext from '../../contexts/AppInfosContext'; - -const useAppInfos = () => { - const appInfos = useContext(AppInfosContext); - - return appInfos; -}; - -export default useAppInfos; diff --git a/packages/core/helper-plugin/src/hooks/useAutoReloadOverlayBlocker/index.js b/packages/core/helper-plugin/src/hooks/useAutoReloadOverlayBlocker/index.js deleted file mode 100644 index 25f6397ddb..0000000000 --- a/packages/core/helper-plugin/src/hooks/useAutoReloadOverlayBlocker/index.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * - * useAutoReloadOverlayBlocker - * - */ - -import { useContext, useRef } from 'react'; -import AutoReloadOverlayBlockerContext from '../../contexts/AutoReloadOverlayBockerContext'; - -const useAutoReloadOverlayBlocker = () => { - const { lockApp, unlockApp } = useContext(AutoReloadOverlayBlockerContext); - // Use a ref so we can safely add the components or the fields - // to a hook dependencies array - const lockAppRef = useRef(lockApp); - const unlockAppRef = useRef(unlockApp); - - return { - lockAppWithAutoreload: lockAppRef.current, - unlockAppWithAutoreload: unlockAppRef.current, - }; -}; - -export default useAutoReloadOverlayBlocker; diff --git a/packages/core/admin/admin/src/content-manager/hooks/useCallbackRef.js b/packages/core/helper-plugin/src/hooks/useCallbackRef.js similarity index 100% rename from packages/core/admin/admin/src/content-manager/hooks/useCallbackRef.js rename to packages/core/helper-plugin/src/hooks/useCallbackRef.js diff --git a/packages/core/helper-plugin/src/hooks/useCustomFields/index.js b/packages/core/helper-plugin/src/hooks/useCustomFields/index.js deleted file mode 100644 index 6ca66e35b9..0000000000 --- a/packages/core/helper-plugin/src/hooks/useCustomFields/index.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * - * useCustomFields - * - */ - -import { useContext, useRef } from 'react'; -import CustomFieldsContext from '../../contexts/CustomFieldsContext'; - -const useCustomFields = () => { - const customFields = useContext(CustomFieldsContext); - // Use a ref so we can safely add the custom fields to a hook dependencies array - const customFieldsRef = useRef(customFields); - - return customFieldsRef.current; -}; - -export default useCustomFields; diff --git a/packages/core/helper-plugin/src/hooks/useGuidedTour/index.js b/packages/core/helper-plugin/src/hooks/useGuidedTour/index.js deleted file mode 100644 index 0ed1d267a9..0000000000 --- a/packages/core/helper-plugin/src/hooks/useGuidedTour/index.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * - * useGuidedTour - * - */ - -import { useContext } from 'react'; -import GuidedTourContext from '../../contexts/GuidedTourContext'; - -const useGuidedTour = () => { - const guidedTour = useContext(GuidedTourContext); - - return guidedTour; -}; - -export default useGuidedTour; diff --git a/packages/core/helper-plugin/src/hooks/useLibrary/index.js b/packages/core/helper-plugin/src/hooks/useLibrary/index.js deleted file mode 100644 index 0e664510d0..0000000000 --- a/packages/core/helper-plugin/src/hooks/useLibrary/index.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * - * useLibrary - * - */ - -import { useContext, useRef } from 'react'; -import LibraryContext from '../../contexts/LibraryContext'; - -const useLibrary = () => { - const { components, fields } = useContext(LibraryContext); - // Use a ref so we can safely add the components or the fields - // to a hook dependencies array - const composRef = useRef(components); - const fieldsRef = useRef(fields); - - return { components: composRef.current, fields: fieldsRef.current }; -}; - -export default useLibrary; diff --git a/packages/core/helper-plugin/src/hooks/useNotification/index.js b/packages/core/helper-plugin/src/hooks/useNotification/index.js deleted file mode 100644 index 824ff1206f..0000000000 --- a/packages/core/helper-plugin/src/hooks/useNotification/index.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * - * useNotification - * - */ - -import { useContext, useRef } from 'react'; -import NotificationsContext from '../../contexts/NotificationsContext'; - -const useNotification = () => { - const { toggleNotification } = useContext(NotificationsContext); - // Use a ref so we can safely add the toggleNotification - // to a hook dependencies array - const toggleNotificationRef = useRef(toggleNotification); - - return toggleNotificationRef.current; -}; - -export default useNotification; diff --git a/packages/core/helper-plugin/src/hooks/useOverlayBlocker/index.js b/packages/core/helper-plugin/src/hooks/useOverlayBlocker/index.js deleted file mode 100644 index 9d06eaccce..0000000000 --- a/packages/core/helper-plugin/src/hooks/useOverlayBlocker/index.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * - * useOverlayBlocker - * - */ - -import { useContext, useRef } from 'react'; -import OverlayBlockerContext from '../../contexts/OverlayBlockerContext'; - -const useOverlayBlocker = () => { - const { lockApp, unlockApp } = useContext(OverlayBlockerContext); - // Use a ref so we can safely add the components or the fields - // to a hook dependencies array - const lockAppRef = useRef(lockApp); - const unlockAppRef = useRef(unlockApp); - - return { lockApp: lockAppRef.current, unlockApp: unlockAppRef.current }; -}; - -export default useOverlayBlocker; diff --git a/packages/core/helper-plugin/src/hooks/useRBAC/index.js b/packages/core/helper-plugin/src/hooks/useRBAC/index.js index 6e65a14bef..6dd26b2483 100644 --- a/packages/core/helper-plugin/src/hooks/useRBAC/index.js +++ b/packages/core/helper-plugin/src/hooks/useRBAC/index.js @@ -4,7 +4,7 @@ import hasPermissions from '../../utils/hasPermissions'; import generateResultsObject from './utils/generateResultsObject'; import reducer from './reducer'; import init from './init'; -import useRBACProvider from '../useRBACProvider'; +import { useRBACProvider } from '../../features/RBAC'; const useRBAC = (pluginPermissions, permissions) => { const abortController = new AbortController(); diff --git a/packages/core/helper-plugin/src/hooks/useRBACProvider/index.js b/packages/core/helper-plugin/src/hooks/useRBACProvider/index.js deleted file mode 100644 index 75c5ee87d8..0000000000 --- a/packages/core/helper-plugin/src/hooks/useRBACProvider/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * useRBACProvider - * - */ - -import { useContext } from 'react'; -import RBACProviderContext from '../../contexts/RBACProviderContext'; - -const useRBACProvider = () => useContext(RBACProviderContext); - -export default useRBACProvider; diff --git a/packages/core/helper-plugin/src/hooks/useStrapiApp/index.js b/packages/core/helper-plugin/src/hooks/useStrapiApp/index.js deleted file mode 100644 index c673474d3c..0000000000 --- a/packages/core/helper-plugin/src/hooks/useStrapiApp/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * - * useStrapiApp - * - */ - -import { useContext } from 'react'; -import StrapiAppContext from '../../contexts/StrapiAppContext'; - -const useStrapiApp = () => useContext(StrapiAppContext); - -export default useStrapiApp; diff --git a/packages/core/helper-plugin/src/hooks/useTracking/index.js b/packages/core/helper-plugin/src/hooks/useTracking/index.js deleted file mode 100644 index 37c29b8c1e..0000000000 --- a/packages/core/helper-plugin/src/hooks/useTracking/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import { useContext, useRef } from 'react'; -import axios from 'axios'; -import TrackingContext from '../../contexts/TrackingContext'; -import useAppInfos from '../useAppInfos'; - -const useTracking = () => { - const trackRef = useRef(); - const { uuid, telemetryProperties, deviceId } = useContext(TrackingContext); - const appInfo = useAppInfos(); - const userId = appInfo?.userId; - - trackRef.current = async (event, properties) => { - if (uuid && !window.strapi.telemetryDisabled) { - try { - await axios.post( - 'https://analytics.strapi.io/api/v2/track', - { - event, - userId, - deviceId, - eventProperties: { ...properties }, - userProperties: {}, - groupProperties: { - ...telemetryProperties, - projectId: uuid, - projectType: window.strapi.projectType, - }, - }, - { - headers: { 'Content-Type': 'application/json' }, - } - ); - } catch (err) { - // Silent - } - } - }; - - return { trackUsage: trackRef.current }; -}; - -export default useTracking; diff --git a/packages/core/helper-plugin/src/hooks/useTracking/tests/index.test.js b/packages/core/helper-plugin/src/hooks/useTracking/tests/index.test.js deleted file mode 100644 index c9a21fb27e..0000000000 --- a/packages/core/helper-plugin/src/hooks/useTracking/tests/index.test.js +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import axios from 'axios'; - -import TrackingContext from '../../../contexts/TrackingContext'; -import useTracking from '..'; -import useAppInfos from '../../useAppInfos'; - -jest.mock('../../useAppInfos'); - -jest.mock('axios', () => ({ - ...jest.requireActual('axios'), - post: jest.fn(), -})); - -function setup(props) { - return new Promise((resolve) => { - act(() => { - resolve( - renderHook(() => useTracking(), { - wrapper: ({ children }) => ( - - {children} - - ), - }) - ); - }); - }); -} - -describe('useTracking', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - test('Call trackUsage() with all attributes', async () => { - useAppInfos.mockReturnValue({ - currentEnvironment: 'testing', - userId: 'someTestUserId', - }); - - const { result } = await setup(); - - result.current.trackUsage('event', { trackingProperty: true }); - - expect(axios.post).toBeCalledWith( - expect.any(String), - { - userId: 'someTestUserId', - deviceId: 'someTestDeviceId', - event: 'event', - eventProperties: { - trackingProperty: true, - }, - groupProperties: { - nestedProperty: true, - projectId: 1, - projectType: 'Community', - }, - userProperties: {}, - }, - { - headers: { 'Content-Type': 'application/json' }, - } - ); - }); - - test('Do not track if it has been disabled', async () => { - window.strapi.telemetryDisabled = true; - - const { result } = await setup(); - - result.current.trackUsage(); - - expect(axios.post).not.toBeCalled(); - }); - - test('Do not track if no uuid was set', async () => { - window.strapi.telemetryDisabled = true; - - const { result } = await setup({ - uuid: null, - }); - - result.current.trackUsage(); - - expect(axios.post).not.toBeCalled(); - - window.strapi.telemetryDisabled = false; - }); - - test('Should fail gracefully if the request does not work', async () => { - axios.post = jest.fn().mockRejectedValueOnce({}); - - const { result } = await setup(); - - expect(result.current.trackUsage).not.toThrow(); - }); -}); diff --git a/packages/core/helper-plugin/src/index.js b/packages/core/helper-plugin/src/index.js index ffa73f2bd3..5e0bb5f9e4 100644 --- a/packages/core/helper-plugin/src/index.js +++ b/packages/core/helper-plugin/src/index.js @@ -1,49 +1,9 @@ import { getType, getOtherInfos } from './content-manager/utils/getAttributeInfos'; -// Contexts -export { default as AppInfosContext } from './contexts/AppInfosContext'; -export { default as AutoReloadOverlayBockerContext } from './contexts/AutoReloadOverlayBockerContext'; -export { default as NotificationsContext } from './contexts/NotificationsContext'; -export { default as OverlayBlockerContext } from './contexts/OverlayBlockerContext'; +/* ------------------------------------------------------------------------------------------------- + * Components + * -----------------------------------------------------------------------------------------------*/ -export { default as RBACProviderContext } from './contexts/RBACProviderContext'; -export { default as TrackingContext } from './contexts/TrackingContext'; - -// Hooks -export { default as useGuidedTour } from './hooks/useGuidedTour'; -export { default as useAppInfos } from './hooks/useAppInfos'; -export { default as useQuery } from './hooks/useQuery'; -export { default as useLibrary } from './hooks/useLibrary'; -export { default as useCustomFields } from './hooks/useCustomFields'; -export { default as useNotification } from './hooks/useNotification'; -export { default as useStrapiApp } from './hooks/useStrapiApp'; -export { default as useTracking } from './hooks/useTracking'; -export { useSelectionState } from './hooks/useSelectionState'; -export * from './hooks/useAPIErrorHandler'; -export { useFilter } from './hooks/useFilter'; -export { useCollator } from './hooks/useCollator'; - -export { default as useQueryParams } from './hooks/useQueryParams'; -export { default as useOverlayBlocker } from './hooks/useOverlayBlocker'; -export { default as useAutoReloadOverlayBlocker } from './hooks/useAutoReloadOverlayBlocker'; -export { default as useRBACProvider } from './hooks/useRBACProvider'; -export { default as useRBAC } from './hooks/useRBAC'; -export { default as usePersistentState } from './hooks/usePersistentState'; -export { default as useFocusWhenNavigate } from './hooks/useFocusWhenNavigate'; -export { default as useLockScroll } from './hooks/useLockScroll'; -export { default as useFetchClient } from './hooks/useFetchClient'; - -// Providers -export { default as GuidedTourProvider } from './providers/GuidedTourProvider'; -export { default as LibraryProvider } from './providers/LibraryProvider'; -export { default as CustomFieldsProvider } from './providers/CustomFieldsProvider'; -export { default as NotificationsProvider } from './providers/NotificationsProvider'; -export { default as StrapiAppProvider } from './providers/StrapiAppProvider'; -export { default as TrackingProvider } from './providers/TrackingProvider'; - -// Utils - -// New components export { default as CheckPagePermissions } from './components/CheckPagePermissions'; export { default as CheckPermissions } from './components/CheckPermissions'; export { default as ConfirmDialog } from './components/ConfirmDialog'; @@ -73,17 +33,59 @@ export { default as ReactSelect } from './components/ReactSelect'; export { default as Link } from './components/Link'; export { default as LinkButton } from './components/LinkButton'; -// New icons -export { default as SortIcon } from './icons/SortIcon'; -export { default as RemoveRoundedButton } from './icons/RemoveRoundedButton'; +/* ------------------------------------------------------------------------------------------------- + * Content Manager + * -----------------------------------------------------------------------------------------------*/ -// content-manager export { default as ContentManagerEditViewDataManagerContext } from './content-manager/contexts/ContentManagerEditViewDataManagerContext'; export { default as useCMEditViewDataManager } from './content-manager/hooks/useCMEditViewDataManager'; export { getType }; export { getOtherInfos }; -// Utils +/* ------------------------------------------------------------------------------------------------- + * Features + * -----------------------------------------------------------------------------------------------*/ + +export * from './features/AppInfo'; +export * from './features/AutoReloadOverlayBlocker'; +export * from './features/CustomFields'; +export * from './features/GuidedTour'; +export * from './features/Library'; +export * from './features/OverlayBlocker'; +export * from './features/Notifications'; +export * from './features/RBAC'; +export * from './features/StrapiApp'; +export * from './features/Tracking'; + +/* ------------------------------------------------------------------------------------------------- + * Hooks + * -----------------------------------------------------------------------------------------------*/ + +export { default as useQuery } from './hooks/useQuery'; +export { useSelectionState } from './hooks/useSelectionState'; +export * from './hooks/useAPIErrorHandler'; +export { useFilter } from './hooks/useFilter'; +export { useCollator } from './hooks/useCollator'; +export { useCallbackRef } from './hooks/useCallbackRef'; + +export { default as useQueryParams } from './hooks/useQueryParams'; +export { default as useRBAC } from './hooks/useRBAC'; +export { default as usePersistentState } from './hooks/usePersistentState'; +export { default as useFocusWhenNavigate } from './hooks/useFocusWhenNavigate'; +export { default as useLockScroll } from './hooks/useLockScroll'; +export { default as useFetchClient } from './hooks/useFetchClient'; + +/* ------------------------------------------------------------------------------------------------- + * Icons + * -----------------------------------------------------------------------------------------------*/ + +export { default as SortIcon } from './icons/SortIcon'; +export { default as RemoveRoundedButton } from './icons/RemoveRoundedButton'; + +/* ------------------------------------------------------------------------------------------------- + * Utils + * -----------------------------------------------------------------------------------------------*/ + export { default as auth } from './utils/auth'; export { default as hasPermissions } from './utils/hasPermissions'; export { default as prefixFileUrlWithBackendUrl } from './utils/prefixFileUrlWithBackendUrl/prefixFileUrlWithBackendUrl'; diff --git a/packages/core/helper-plugin/src/providers/CustomFieldsProvider/index.js b/packages/core/helper-plugin/src/providers/CustomFieldsProvider/index.js deleted file mode 100644 index 6805cddc24..0000000000 --- a/packages/core/helper-plugin/src/providers/CustomFieldsProvider/index.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * - * CustomFieldsProvider - * - */ -import React from 'react'; -import PropTypes from 'prop-types'; -import CustomFieldsContext from '../../contexts/CustomFieldsContext'; - -const CustomFieldsProvider = ({ children, customFields }) => { - return ( - - {children} - - ); -}; - -CustomFieldsProvider.propTypes = { - children: PropTypes.node.isRequired, - customFields: PropTypes.shape({ - get: PropTypes.func.isRequired, - getAll: PropTypes.func.isRequired, - }).isRequired, -}; - -export default CustomFieldsProvider; diff --git a/packages/core/helper-plugin/src/providers/GuidedTourProvider/index.js b/packages/core/helper-plugin/src/providers/GuidedTourProvider/index.js deleted file mode 100644 index 16ba8dd589..0000000000 --- a/packages/core/helper-plugin/src/providers/GuidedTourProvider/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import GuidedTourContext from '../../contexts/GuidedTourContext'; - -const GuidedTourProvider = ({ children, ...value }) => { - return {children}; -}; - -GuidedTourProvider.defaultProps = { - currentStep: null, - isGuidedTourVisible: false, -}; - -GuidedTourProvider.propTypes = { - children: PropTypes.node.isRequired, - currentStep: PropTypes.string, - guidedTourState: PropTypes.objectOf( - PropTypes.shape({ - create: PropTypes.bool, - success: PropTypes.bool, - }) - ).isRequired, - isGuidedTourVisible: PropTypes.bool, - isSkipped: PropTypes.bool.isRequired, - setCurrentStep: PropTypes.func.isRequired, - setGuidedTourVisibility: PropTypes.func.isRequired, - setSkipped: PropTypes.func.isRequired, - setStepState: PropTypes.func.isRequired, - startSection: PropTypes.func.isRequired, -}; - -export default GuidedTourProvider; diff --git a/packages/core/helper-plugin/src/providers/LibraryProvider/index.js b/packages/core/helper-plugin/src/providers/LibraryProvider/index.js deleted file mode 100644 index eed9d9ef02..0000000000 --- a/packages/core/helper-plugin/src/providers/LibraryProvider/index.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * - * LibraryProvider - * - */ -import React from 'react'; -import PropTypes from 'prop-types'; -import LibraryContext from '../../contexts/LibraryContext'; - -const LibraryProvider = ({ children, ...value }) => { - return {children}; -}; - -LibraryProvider.propTypes = { - children: PropTypes.node.isRequired, - components: PropTypes.object.isRequired, - fields: PropTypes.object.isRequired, -}; - -export default LibraryProvider; diff --git a/packages/core/helper-plugin/src/providers/NotificationsProvider/index.js b/packages/core/helper-plugin/src/providers/NotificationsProvider/index.js deleted file mode 100644 index 4570300bef..0000000000 --- a/packages/core/helper-plugin/src/providers/NotificationsProvider/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * - * NotificationsProvider - * - */ -import React, { useMemo } from 'react'; -import PropTypes from 'prop-types'; -import NotificationsContext from '../../contexts/NotificationsContext'; - -const NotificationsProvider = ({ children, toggleNotification }) => { - const notificationValue = useMemo(() => ({ toggleNotification }), [toggleNotification]); - - return ( - - {children} - - ); -}; - -NotificationsProvider.propTypes = { - children: PropTypes.node.isRequired, - toggleNotification: PropTypes.func.isRequired, -}; - -export default NotificationsProvider; diff --git a/packages/core/helper-plugin/src/providers/StrapiAppProvider/index.js b/packages/core/helper-plugin/src/providers/StrapiAppProvider/index.js deleted file mode 100644 index 7977be9529..0000000000 --- a/packages/core/helper-plugin/src/providers/StrapiAppProvider/index.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * - * StrapiAppProvider - * - */ -import React from 'react'; -import PropTypes from 'prop-types'; -import StrapiAppContext from '../../contexts/StrapiAppContext'; - -const StrapiAppProvider = ({ children, ...value }) => { - return {children}; -}; - -StrapiAppProvider.propTypes = { - children: PropTypes.node.isRequired, - getPlugin: PropTypes.func.isRequired, - menu: PropTypes.arrayOf( - PropTypes.shape({ - to: PropTypes.string.isRequired, - icon: PropTypes.func.isRequired, - intlLabel: PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, - }).isRequired, - permissions: PropTypes.array, - Component: PropTypes.func, - }) - ).isRequired, - plugins: PropTypes.object.isRequired, - runHookParallel: PropTypes.func.isRequired, - runHookWaterfall: PropTypes.func.isRequired, - runHookSeries: PropTypes.func.isRequired, - settings: PropTypes.object.isRequired, -}; - -export default StrapiAppProvider; diff --git a/packages/core/helper-plugin/src/providers/TrackingProvider/index.js b/packages/core/helper-plugin/src/providers/TrackingProvider/index.js deleted file mode 100644 index 5d97825d90..0000000000 --- a/packages/core/helper-plugin/src/providers/TrackingProvider/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import TrackingContext from '../../contexts/TrackingContext'; - -const DEFAULT_VALUE = { uuid: false, telemetryProperties: undefined }; - -const TrackingProvider = (props) => { - return ; -}; - -TrackingProvider.propTypes = { - value: PropTypes.shape({ - uuid: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - telemetryProperties: PropTypes.object, - }), -}; - -TrackingProvider.defaultProps = { - value: DEFAULT_VALUE, -}; - -export default TrackingProvider; diff --git a/packages/core/helper-plugin/webpack.config.js b/packages/core/helper-plugin/webpack.config.js index b6da26c2dc..a0420f37cb 100644 --- a/packages/core/helper-plugin/webpack.config.js +++ b/packages/core/helper-plugin/webpack.config.js @@ -48,10 +48,6 @@ const baseConfig = { }, ], }, - resolve: { - extensions: ['*', '.js'], - cacheWithContext: false, - }, plugins: [ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', diff --git a/packages/core/permissions/jest.config.js b/packages/core/permissions/jest.config.js index b2889fdc0c..65395f7593 100644 --- a/packages/core/permissions/jest.config.js +++ b/packages/core/permissions/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core permissions', }; diff --git a/packages/core/permissions/package.json b/packages/core/permissions/package.json index f7a321e096..0078a33025 100644 --- a/packages/core/permissions/package.json +++ b/packages/core/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/permissions", - "version": "4.9.2", + "version": "4.10.1", "description": "Strapi's permission layer.", "repository": { "type": "git", @@ -27,7 +27,7 @@ }, "dependencies": { "@casl/ability": "5.4.4", - "@strapi/utils": "4.9.2", + "@strapi/utils": "4.10.1", "lodash": "4.17.21", "sift": "16.0.1" }, diff --git a/packages/core/strapi/bin/strapi.js b/packages/core/strapi/bin/strapi.js index e255fa3524..c787bc82bc 100755 --- a/packages/core/strapi/bin/strapi.js +++ b/packages/core/strapi/bin/strapi.js @@ -2,472 +2,6 @@ 'use strict'; -// FIXME -/* eslint-disable import/extensions */ -const _ = require('lodash'); -const path = require('path'); -const resolveCwd = require('resolve-cwd'); -const { yellow } = require('chalk'); -const { Command, Option } = require('commander'); -const inquirer = require('inquirer'); +const { runStrapiCommand } = require('../lib/commands'); -const program = new Command(); - -const packageJSON = require('../package.json'); -const { - promptEncryptionKey, - confirmMessage, - forceOption, - parseURL, -} = require('../lib/commands/utils/commander'); -const { exitWith, ifOptions, assertUrlHasProtocol } = require('../lib/commands/utils/helpers'); -const { - excludeOption, - onlyOption, - throttleOption, - validateExcludeOnly, -} = require('../lib/commands/transfer/utils'); - -const checkCwdIsStrapiApp = (name) => { - const logErrorAndExit = () => { - console.log( - `You need to run ${yellow( - `strapi ${name}` - )} in a Strapi project. Make sure you are in the right directory.` - ); - process.exit(1); - }; - - try { - const pkgJSON = require(`${process.cwd()}/package.json`); - if (!_.has(pkgJSON, 'dependencies.@strapi/strapi')) { - logErrorAndExit(name); - } - } catch (err) { - logErrorAndExit(name); - } -}; - -const getLocalScript = - (name) => - (...args) => { - checkCwdIsStrapiApp(name); - - const cmdPath = resolveCwd.silent(`@strapi/strapi/lib/commands/${name}`); - if (!cmdPath) { - console.log( - `Error loading the local ${yellow( - name - )} command. Strapi might not be installed in your "node_modules". You may need to run "yarn install".` - ); - process.exit(1); - } - - const script = require(cmdPath); - - Promise.resolve() - .then(() => { - return script(...args); - }) - .catch((error) => { - console.error(error); - process.exit(1); - }); - }; - -// Initial program setup -program.storeOptionsAsProperties(false).allowUnknownOption(true); - -program.helpOption('-h, --help', 'Display help for command'); -program.addHelpCommand('help [command]', 'Display help for command'); - -// `$ strapi version` (--version synonym) -program.version(packageJSON.version, '-v, --version', 'Output the version number'); -program - .command('version') - .description('Output the version of Strapi') - .action(() => { - process.stdout.write(`${packageJSON.version}\n`); - process.exit(0); - }); - -// `$ strapi console` -program - .command('console') - .description('Open the Strapi framework console') - .action(getLocalScript('console')); - -// `$ strapi new` -program - .command('new ') - .option('--no-run', 'Do not start the application after it is created') - .option('--use-npm', 'Force usage of npm instead of yarn to create the project') - .option('--debug', 'Display database connection errors') - .option('--quickstart', 'Create quickstart app') - .option('--dbclient ', 'Database client') - .option('--dbhost ', 'Database host') - .option('--dbport ', 'Database port') - .option('--dbname ', 'Database name') - .option('--dbusername ', 'Database username') - .option('--dbpassword ', 'Database password') - .option('--dbssl ', 'Database SSL') - .option('--dbfile ', 'Database file path for sqlite') - .option('--dbforce', 'Allow overwriting existing database content') - .option('-ts, --typescript', 'Create a typescript project') - .description('Create a new application') - .action(require('../lib/commands/new')); - -// `$ strapi start` -program - .command('start') - .description('Start your Strapi application') - .action(getLocalScript('start')); - -// `$ strapi develop` -program - .command('develop') - .alias('dev') - .option('--no-build', 'Disable build') - .option('--watch-admin', 'Enable watch', false) - .option('--polling', 'Watch for file changes in network directories', false) - .option('--browser ', 'Open the browser', true) - .description('Start your Strapi application in development mode') - .action(getLocalScript('develop')); - -// $ strapi generate -program - .command('generate') - .description('Launch the interactive API generator') - .action(() => { - checkCwdIsStrapiApp('generate'); - process.argv.splice(2, 1); - require('@strapi/generators').runCLI(); - }); - -// `$ strapi generate:template ` -program - .command('templates:generate ') - .description('Generate template from Strapi project') - .action(getLocalScript('generate-template')); - -program - .command('build') - .option('--no-optimization', 'Build the admin app without optimizing assets') - .description('Build the strapi admin app') - .action(getLocalScript('build')); - -// `$ strapi install` -program - .command('install [plugins...]') - .description('Install a Strapi plugin') - .action(getLocalScript('install')); - -// `$ strapi uninstall` -program - .command('uninstall [plugins...]') - .description('Uninstall a Strapi plugin') - .option('-d, --delete-files', 'Delete files', false) - .action(getLocalScript('uninstall')); - -// `$ strapi watch-admin` -program - .command('watch-admin') - .option('--browser ', 'Open the browser', true) - .description('Start the admin development server') - .action(getLocalScript('watchAdmin')); - -program - .command('configuration:dump') - .alias('config:dump') - .description('Dump configurations of your application') - .option('-f, --file ', 'Output file, default output is stdout') - .option('-p, --pretty', 'Format the output JSON with indentation and line breaks', false) - .action(getLocalScript('configurationDump')); - -program - .command('configuration:restore') - .alias('config:restore') - .description('Restore configurations of your application') - .option('-f, --file ', 'Input file, default input is stdin') - .option('-s, --strategy ', 'Strategy name, one of: "replace", "merge", "keep"') - .action(getLocalScript('configurationRestore')); - -// Admin -program - .command('admin:create-user') - .alias('admin:create') - .description('Create a new admin') - .option('-e, --email ', 'Email of the new admin') - .option('-p, --password ', 'Password of the new admin') - .option('-f, --firstname ', 'First name of the new admin') - .option('-l, --lastname ', 'Last name of the new admin') - .action(getLocalScript('admin-create')); - -program - .command('admin:reset-user-password') - .alias('admin:reset-password') - .description("Reset an admin user's password") - .option('-e, --email ', 'The user email') - .option('-p, --password ', 'New password for the user') - .action(getLocalScript('admin-reset')); - -program - .command('routes:list') - .description('List all the application routes') - .action(getLocalScript('routes/list')); - -program - .command('middlewares:list') - .description('List all the application middlewares') - .action(getLocalScript('middlewares/list')); - -program - .command('policies:list') - .description('List all the application policies') - .action(getLocalScript('policies/list')); - -program - .command('content-types:list') - .description('List all the application content-types') - .action(getLocalScript('content-types/list')); - -program - .command('hooks:list') - .description('List all the application hooks') - .action(getLocalScript('hooks/list')); - -program - .command('services:list') - .description('List all the application services') - .action(getLocalScript('services/list')); - -program - .command('controllers:list') - .description('List all the application controllers') - .action(getLocalScript('controllers/list')); - -// `$ strapi opt-out-telemetry` -program - .command('telemetry:disable') - .description('Disable anonymous telemetry and metadata sending to Strapi analytics') - .action(getLocalScript('opt-out-telemetry')); - -// `$ strapi opt-in-telemetry` -program - .command('telemetry:enable') - .description('Enable anonymous telemetry and metadata sending to Strapi analytics') - .action(getLocalScript('opt-in-telemetry')); - -program - .command('report') - .description('Get system stats for debugging and submitting issues') - .option('-u, --uuid', 'Include Project UUID') - .option('-d, --dependencies', 'Include Project Dependencies') - .option('--all', 'Include All Information') - .action(getLocalScript('report')); - -program - .command('ts:generate-types') - .description(`Generate TypeScript typings for your schemas`) - .option( - '-o, --out-dir ', - 'Specify a relative directory in which the schemas definitions will be generated' - ) - .option('-f, --file ', 'Specify a filename to store the schemas definitions') - .option('--verbose', `Display more information about the types generation`, false) - .option('-s, --silent', `Run the generation silently, without any output`, false) - .action(getLocalScript('ts/generate-types')); - -// `$ strapi transfer` -program - .command('transfer') - .description('Transfer data from one source to another') - .allowExcessArguments(false) - .addOption( - new Option( - '--from ', - `URL of the remote Strapi instance to get data from` - ).argParser(parseURL) - ) - .addOption(new Option('--from-token ', `Transfer token for the remote Strapi source`)) - .addOption( - new Option( - '--to ', - `URL of the remote Strapi instance to send data to` - ).argParser(parseURL) - ) - .addOption(new Option('--to-token ', `Transfer token for the remote Strapi destination`)) - .addOption(forceOption) - .addOption(excludeOption) - .addOption(onlyOption) - .addOption(throttleOption) - .hook('preAction', validateExcludeOnly) - .hook( - 'preAction', - ifOptions( - (opts) => !(opts.from || opts.to) || (opts.from && opts.to), - () => - exitWith(1, 'Exactly one remote source (from) or destination (to) option must be provided') - ) - ) - // If --from is used, validate the URL and token - .hook( - 'preAction', - ifOptions( - (opts) => opts.from, - async (thisCommand) => { - assertUrlHasProtocol(thisCommand.opts().from, ['https:', 'http:']); - if (!thisCommand.opts().fromToken) { - const answers = await inquirer.prompt([ - { - type: 'password', - message: 'Please enter your transfer token for the remote Strapi source', - name: 'fromToken', - }, - ]); - if (!answers.fromToken?.length) { - exitWith(1, 'No token provided for remote source, aborting transfer.'); - } - thisCommand.opts().fromToken = answers.fromToken; - } - - await confirmMessage( - 'The transfer will delete all the local Strapi assets and its database. Are you sure you want to proceed?', - { failMessage: 'Transfer process aborted' } - )(thisCommand); - } - ) - ) - // If --to is used, validate the URL, token, and confirm restore - .hook( - 'preAction', - ifOptions( - (opts) => opts.to, - async (thisCommand) => { - assertUrlHasProtocol(thisCommand.opts().to, ['https:', 'http:']); - if (!thisCommand.opts().toToken) { - const answers = await inquirer.prompt([ - { - type: 'password', - message: 'Please enter your transfer token for the remote Strapi destination', - name: 'toToken', - }, - ]); - if (!answers.toToken?.length) { - exitWith(1, 'No token provided for remote destination, aborting transfer.'); - } - thisCommand.opts().toToken = answers.toToken; - } - - await confirmMessage( - 'The transfer will delete all the remote Strapi assets and its database. Are you sure you want to proceed?', - { failMessage: 'Transfer process aborted' } - )(thisCommand); - } - ) - ) - .action(getLocalScript('transfer/transfer')); - -// `$ strapi export` -program - .command('export') - .description('Export data from Strapi to file') - .allowExcessArguments(false) - .addOption( - new Option('--no-encrypt', `Disables 'aes-128-ecb' encryption of the output file`).default(true) - ) - .addOption(new Option('--no-compress', 'Disables gzip compression of output file').default(true)) - .addOption( - new Option( - '-k, --key ', - 'Provide encryption key in command instead of using the prompt' - ) - ) - .addOption(new Option('-f, --file ', 'name to use for exported file (without extensions)')) - .addOption(excludeOption) - .addOption(onlyOption) - .addOption(throttleOption) - .hook('preAction', validateExcludeOnly) - .hook('preAction', promptEncryptionKey) - .action(getLocalScript('transfer/export')); - -// `$ strapi import` -program - .command('import') - .description('Import data from file to Strapi') - .allowExcessArguments(false) - .requiredOption( - '-f, --file ', - 'path and filename for the Strapi export file you want to import' - ) - .addOption( - new Option( - '-k, --key ', - 'Provide encryption key in command instead of using the prompt' - ) - ) - .addOption(forceOption) - .addOption(excludeOption) - .addOption(onlyOption) - .addOption(throttleOption) - .hook('preAction', validateExcludeOnly) - .hook('preAction', async (thisCommand) => { - const opts = thisCommand.opts(); - const ext = path.extname(String(opts.file)); - - // check extension to guess if we should prompt for key - if (ext === '.enc') { - if (!opts.key) { - const answers = await inquirer.prompt([ - { - type: 'password', - message: 'Please enter your decryption key', - name: 'key', - }, - ]); - if (!answers.key?.length) { - exitWith(1, 'No key entered, aborting import.'); - } - opts.key = answers.key; - } - } - }) - // set decrypt and decompress options based on filename - .hook('preAction', (thisCommand) => { - const opts = thisCommand.opts(); - - const { extname, parse } = path; - - let file = opts.file; - - if (extname(file) === '.enc') { - file = parse(file).name; // trim the .enc extension - thisCommand.opts().decrypt = true; - } else { - thisCommand.opts().decrypt = false; - } - - if (extname(file) === '.gz') { - file = parse(file).name; // trim the .gz extension - thisCommand.opts().decompress = true; - } else { - thisCommand.opts().decompress = false; - } - - if (extname(file) !== '.tar') { - exitWith( - 1, - `The file '${opts.file}' does not appear to be a valid Strapi data file. It must have an extension ending in .tar[.gz][.enc]` - ); - } - }) - .hook( - 'preAction', - confirmMessage( - 'The import will delete all assets and data in your database. Are you sure you want to proceed?', - { failMessage: 'Import process aborted' } - ) - ) - .action(getLocalScript('transfer/import')); - -program.parseAsync(process.argv); +runStrapiCommand(process.argv); diff --git a/packages/core/strapi/ee/LICENSE.txt b/packages/core/strapi/ee/LICENSE.txt new file mode 100644 index 0000000000..3d0564dcd3 --- /dev/null +++ b/packages/core/strapi/ee/LICENSE.txt @@ -0,0 +1,21 @@ +This Strapi Enterprise Edition (EE) supplemental license (this “EE Supplemental License”) governs the use of this software and documentation (collectively, the “EE Software”) by you and any entity you represent (collectively, “You”). If You have separately entered into the Strapi, Inc. Enterprise Agreement (the “Enterprise Agreement”), then this EE Supplemental License hereby incorporates by reference the Enterprise Agreement and modifies the Enterprise Agreement solely to the extent set forth herein. If You have separately entered into the Strapi, Inc. Subscription Agreement (the “Subscription Agreement”), then this EE Supplemental License hereby incorporates by reference the Agreement and modifies the Subscription Agreement solely to the extent set forth herein. If You have not entered into either the Enterprise Agreement or the Subscription Agreement, then You may use the EE Software solely as set forth in Section 2 below. + +In the event of a direct conflict between the terms of this EE Supplemental License and the terms of the Enterprise Agreement or the Subscription Agreement, as applicable, the terms of this EE Supplemental License will control. Except to the extent modified by this EE Supplemental License, the Enterprise Agreement or the Subscription Agreement, as applicable, remain in full force and effect in accordance with its terms. + +By using the EE Software, You hereby agree to the below terms and conditions. + +1. Notwithstanding any terms to the contrary in the Enterprise Agreement or Subscription Agreement, You may copy, modify and publish patches to the EE Software in a production environment (such copies, “Production Copies,” such modifications, “Production Modifications” and such patches, “Production Patches”) if and only if (a) You have agreed to, and are in full compliance with, the Enterprise Agreement or Subscription Agreement, as applicable, and (b) You have a valid license to the EE Software for the correct number of projects. You agree that Strapi and/or its licensors (as applicable) will own all right, title and interest in and to all such Production Copies, Production Modifications and Production Patches. You may display and/or distribute such Production Copies, Production Modifications and Production Patches if and only if (i) You have a valid license to the EE Software for the correct number of projects and (ii) You are in compliance with the Enterprise Agreement or Subscription Agreement, as applicable. You hereby assign to Strapi all right, title and interest in and to all Production Copies, Production Modifications and Production Patches, including all intellectual property rights embodied in or related to the foregoing. + +2. Notwithstanding the foregoing, You may copy and modify the EE Software solely for development and testing purposes (such copies, “Development Copies” and such modifications, “Development Modifications”) with or without a license to the EE Software if your use is in compliance with this Section 2. You agree that Strapi and/or its licensors (as applicable) will own all right, title and interest in and to all Development Copies and Development Modifications and You hereby assign to Strapi all right, title and interest in and to all Development Copies and Development Modifications, including all intellectual property rights embodied in or related to the foregoing. If You do not have a license to the EE Software, then You further agree as follows: + +Other than as expressly set forth in this Section 2, You may not (a) copy or modify the EE Software, (b) create derivative works of the EE Software, (c) remove or modify any notice of any patent, copyright, trademark, or other proprietary rights that appear on or in the EE Software, (d) reverse engineer, decompile, translate, disassemble, or discover the source code of all or any portion of the EE Software, (e) publicly display all or any part of the EE Software, (f) distribute, disclose, market, lease, publish, merge, resell, assign, loan, sublicense, rent, or transfer the EE Software to any third party, (g) use the EE Software for any dial-up, remote access, interactive, or other on-line or hosted service, or to provide a service bureau, time share, or other services to third parties, (h) merge the EE Software into another product, (i) disclose the results of any EE Software performance benchmarks or test results to any third party without Strapi’s prior written consent, (j) use any trademarks, logos, service marks, trade names of Strapi, or any portion thereof, without Strapi’s prior written consent, (k) use the EE Software, or any portion thereof, in a manner that does not comply with applicable law, regulations, or governmental orders, or (l) use or store the EE Software on equipment not owned or controlled by Customer. + +THE EE SOFTWARE IS PROVIDED ON AN “AS IS” BASIS WITHOUT ANY REPRESENTATIONS, WARRANTIES, COVENANTS, OR CONDITIONS OF ANY KIND (EXPRESS OR IMPLIED, STATUTORY OR OTHERWISE), INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, TITLE, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT. FURTHER, STRAPI DOES NOT REPRESENT OR WARRANT THAT (A) THE ACCESS TO OR USE OF THE EE SOFTWARE WILL BE SECURE, TIMELY, UNINTERRUPTED, ERROR-FREE, OR OPERATE IN COMBINATION WITH ANY OTHER HARDWARE, SOFTWARE, SYSTEM, OR DATA, (B) THE EE SOFTWARE WILL MEET YOUR REQUIREMENTS OR EXPECTATIONS, OR OTHERWISE PRODUCE ANY PARTICULAR RESULTS, (C) ERRORS OR DEFECTS WILL BE CORRECTED, PATCHES OR WORKAROUNDS WILL BE PROVIDED, OR STRAPI WILL DETECT ANY BUG IN THE EE SOFTWARE, (D) THE SOFTWARE IS FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS, OR (E) THIRD-PARTY DISRUPTIONS OR SECURITY BREACHES OF THE EE SOFTWARE WILL BE PREVENTED. + +STRAPI WILL NOT BE LIABLE FOR ANY LOSS OF PROFITS OR ANY INDIRECT, SPECIAL, INCIDENTAL, RELIANCE, OR CONSEQUENTIAL DAMAGES OF ANY KIND, REGARDLESS OF THE FORM OF ACTION, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, EVEN IF INFORMED OF THE POSSIBILITY OF SUCH DAMAGES IN ADVANCE. + +STRAPI’S ENTIRE LIABILITY TO YOU FOR USE OF THE EE SOFTWARE WILL NOT EXCEED $100. + +3. You are not granted any other rights beyond what is expressly stated herein and in the Enterprise Agreement or Subscription Agreement, as applicable. + +4. This EE Supplemental License does not apply to Strapi software that is distributed as part of the Strapi Community Edition (CE) (the “CE Software”). diff --git a/packages/core/strapi/ee/index.js b/packages/core/strapi/ee/index.js index f8341facc9..e7b80f1ec7 100644 --- a/packages/core/strapi/ee/index.js +++ b/packages/core/strapi/ee/index.js @@ -105,7 +105,9 @@ const onlineUpdate = async ({ strapi }) => { }; const license = shouldContactRegistry - ? await fetchLicense(ee.licenseInfo.licenseKey, strapi.config.get('uuid')).catch(fallback) + ? await fetchLicense({ strapi }, ee.licenseInfo.licenseKey, strapi.config.get('uuid')).catch( + fallback + ) : storedInfo.license; if (license) { diff --git a/packages/core/strapi/ee/license.js b/packages/core/strapi/ee/license.js index 91b148355c..5c69b11d29 100644 --- a/packages/core/strapi/ee/license.js +++ b/packages/core/strapi/ee/license.js @@ -3,7 +3,6 @@ const fs = require('fs'); const { join } = require('path'); const crypto = require('crypto'); -const fetch = require('node-fetch'); const machineId = require('../lib/utils/machine-id'); @@ -12,7 +11,7 @@ const DEFAULT_FEATURES = { silver: [], // Set a null retention duration to allow the user to override it // The default of 90 days is set in the audit logs service - gold: ['sso', { name: 'audit-logs', options: { retentionDays: null } }], + gold: ['sso', { name: 'audit-logs', options: { retentionDays: null } }, 'review-workflows'], }; const publicKey = fs.readFileSync(join(__dirname, 'resources/key.pub')); @@ -69,12 +68,14 @@ const throwError = () => { throw new LicenseCheckError('Could not proceed to the online validation of your license.', true); }; -const fetchLicense = async (key, projectId) => { - const response = await fetch(`https://license.strapi.io/api/licenses/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, projectId, deviceId: machineId() }), - }).catch(throwError); +const fetchLicense = async ({ strapi }, key, projectId) => { + const response = await strapi + .fetch(`https://license.strapi.io/api/licenses/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, projectId, deviceId: machineId() }), + }) + .catch(throwError); const contentType = response.headers.get('Content-Type'); diff --git a/packages/core/strapi/jest.config.js b/packages/core/strapi/jest.config.js index b2889fdc0c..32ef1e726d 100644 --- a/packages/core/strapi/jest.config.js +++ b/packages/core/strapi/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: '../../../jest-preset.unit.js', + displayName: 'Core Strapi', }; diff --git a/packages/core/strapi/lib/Strapi.js b/packages/core/strapi/lib/Strapi.js index 6c3a5c2ddb..f5b2bf48d7 100644 --- a/packages/core/strapi/lib/Strapi.js +++ b/packages/core/strapi/lib/Strapi.js @@ -27,6 +27,7 @@ const createCustomFields = require('./services/custom-fields'); const createContentAPI = require('./services/content-api'); const createUpdateNotifier = require('./utils/update-notifier'); const createStartupLogger = require('./utils/startup-logger'); +const createStrapiFetch = require('./utils/fetch'); const { LIFECYCLES } = require('./utils/lifecycles'); const ee = require('./utils/ee'); const contentTypesRegistry = require('./core/registries/content-types'); @@ -118,6 +119,7 @@ class Strapi { this.telemetry = createTelemetry(this); this.requestContext = requestContext; this.customFields = createCustomFields(this); + this.fetch = createStrapiFetch(this); createUpdateNotifier(this).notify(); @@ -393,6 +395,7 @@ class Strapi { eventHub: this.eventHub, logger: this.log, configuration: this.config.get('server.webhooks', {}), + fetch: this.fetch, }); this.registerInternalHooks(); diff --git a/packages/core/strapi/lib/commands/__tests__/commands.test.js b/packages/core/strapi/lib/commands/__tests__/commands.test.js new file mode 100644 index 0000000000..8a6289ba9c --- /dev/null +++ b/packages/core/strapi/lib/commands/__tests__/commands.test.js @@ -0,0 +1,20 @@ +'use strict'; + +const { buildStrapiCommand } = require('../index'); + +const consoleMock = { + error: jest.spyOn(console, 'error').mockImplementation(() => {}), +}; + +describe('commands', () => { + afterEach(() => { + consoleMock.error.mockClear(); + }); + + describe('buildStrapiCommand', () => { + it('loads all commands without error', () => { + buildStrapiCommand(); + expect(consoleMock.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/strapi/lib/commands/__tests__/data-transfer/shared/transfer.test.utils.js b/packages/core/strapi/lib/commands/__tests__/commands.test.utils.js similarity index 100% rename from packages/core/strapi/lib/commands/__tests__/data-transfer/shared/transfer.test.utils.js rename to packages/core/strapi/lib/commands/__tests__/commands.test.utils.js diff --git a/packages/core/strapi/lib/commands/__tests__/admin-create.test.js b/packages/core/strapi/lib/commands/actions/admin/create-user/__tests__/admin.create-user.test.js similarity index 99% rename from packages/core/strapi/lib/commands/__tests__/admin-create.test.js rename to packages/core/strapi/lib/commands/actions/admin/create-user/__tests__/admin.create-user.test.js index 264fc75f5e..71d03d358c 100644 --- a/packages/core/strapi/lib/commands/__tests__/admin-create.test.js +++ b/packages/core/strapi/lib/commands/actions/admin/create-user/__tests__/admin.create-user.test.js @@ -24,7 +24,7 @@ const mock = { admin, }; -jest.mock('../../index', () => { +jest.mock('@strapi/strapi', () => { const impl = jest.fn(() => mock); impl.compile = jest.fn(); @@ -33,7 +33,7 @@ jest.mock('../../index', () => { }); const inquirer = require('inquirer'); -const createAdminCommand = require('../admin-create'); +const createAdminCommand = require('../action'); describe('admin:create command', () => { beforeEach(() => { diff --git a/packages/core/strapi/lib/commands/admin-create.js b/packages/core/strapi/lib/commands/actions/admin/create-user/action.js similarity index 97% rename from packages/core/strapi/lib/commands/admin-create.js rename to packages/core/strapi/lib/commands/actions/admin/create-user/action.js index 754c8b156a..916ed2d5d8 100644 --- a/packages/core/strapi/lib/commands/admin-create.js +++ b/packages/core/strapi/lib/commands/actions/admin/create-user/action.js @@ -3,7 +3,7 @@ const { yup } = require('@strapi/utils'); const _ = require('lodash'); const inquirer = require('inquirer'); -const strapi = require('../index'); +const strapi = require('../../../../index'); const emailValidator = yup.string().email('Invalid email address').lowercase(); @@ -57,7 +57,7 @@ const promptQuestions = [ * @param {string} cmdOptions.firstname - new admin's first name * @param {string} [cmdOptions.lastname] - new admin's last name */ -module.exports = async function (cmdOptions = {}) { +module.exports = async (cmdOptions = {}) => { let { email, password, firstname, lastname } = cmdOptions; if ( diff --git a/packages/core/strapi/lib/commands/actions/admin/create-user/command.js b/packages/core/strapi/lib/commands/actions/admin/create-user/command.js new file mode 100644 index 0000000000..3a05bcde2e --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/admin/create-user/command.js @@ -0,0 +1,19 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi admin:create-user` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('admin:create-user') + .alias('admin:create') + .description('Create a new admin') + .option('-e, --email ', 'Email of the new admin') + .option('-p, --password ', 'Password of the new admin') + .option('-f, --firstname ', 'First name of the new admin') + .option('-l, --lastname ', 'Last name of the new admin') + .action(getLocalScript('admin/create-user')); +}; diff --git a/packages/core/strapi/lib/commands/__tests__/admin-reset.test.js b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/__tests__/admin.reset-user-password.test.js similarity index 97% rename from packages/core/strapi/lib/commands/__tests__/admin-reset.test.js rename to packages/core/strapi/lib/commands/actions/admin/reset-user-password/__tests__/admin.reset-user-password.test.js index df1dea218b..d4a0315243 100644 --- a/packages/core/strapi/lib/commands/__tests__/admin-reset.test.js +++ b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/__tests__/admin.reset-user-password.test.js @@ -15,7 +15,7 @@ const mock = { admin, }; -jest.mock('../../index', () => { +jest.mock('@strapi/strapi', () => { const impl = jest.fn(() => mock); impl.compile = jest.fn(); @@ -24,7 +24,7 @@ jest.mock('../../index', () => { }); const inquirer = require('inquirer'); -const resetAdminPasswordCommand = require('../admin-reset'); +const resetAdminPasswordCommand = require('../action'); describe('admin:reset-password command', () => { beforeEach(() => { diff --git a/packages/core/strapi/lib/commands/admin-reset.js b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/action.js similarity index 93% rename from packages/core/strapi/lib/commands/admin-reset.js rename to packages/core/strapi/lib/commands/actions/admin/reset-user-password/action.js index 38d6662c0e..8391eaa43f 100644 --- a/packages/core/strapi/lib/commands/admin-reset.js +++ b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/action.js @@ -2,7 +2,7 @@ const _ = require('lodash'); const inquirer = require('inquirer'); -const strapi = require('../index'); +const strapi = require('../../../../index'); const promptQuestions = [ { type: 'input', name: 'email', message: 'User email?' }, @@ -20,7 +20,7 @@ const promptQuestions = [ * @param {string} cmdOptions.email - user's email * @param {string} cmdOptions.password - user's new password */ -module.exports = async function (cmdOptions = {}) { +module.exports = async (cmdOptions = {}) => { const { email, password } = cmdOptions; if (_.isEmpty(email) && _.isEmpty(password) && process.stdin.isTTY) { diff --git a/packages/core/strapi/lib/commands/actions/admin/reset-user-password/command.js b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/command.js new file mode 100644 index 0000000000..a09fb304dd --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/admin/reset-user-password/command.js @@ -0,0 +1,17 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi admin:reset-user-password` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('admin:reset-user-password') + .alias('admin:reset-password') + .description("Reset an admin user's password") + .option('-e, --email ', 'The user email') + .option('-p, --password ', 'New password for the user') + .action(getLocalScript('admin/reset-user-password')); +}; diff --git a/packages/core/strapi/lib/commands/build.js b/packages/core/strapi/lib/commands/actions/build-command/action.js similarity index 76% rename from packages/core/strapi/lib/commands/build.js rename to packages/core/strapi/lib/commands/actions/build-command/action.js index 29b99dfd8d..15f9de8367 100644 --- a/packages/core/strapi/lib/commands/build.js +++ b/packages/core/strapi/lib/commands/actions/build-command/action.js @@ -1,7 +1,7 @@ 'use strict'; -const strapi = require('..'); -const { buildAdmin } = require('./builders'); +const strapi = require('../../..'); +const { buildAdmin } = require('../../builders'); /** * `$ strapi build` diff --git a/packages/core/strapi/lib/commands/actions/build-command/command.js b/packages/core/strapi/lib/commands/actions/build-command/command.js new file mode 100644 index 0000000000..0eee379884 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/build-command/command.js @@ -0,0 +1,15 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi build` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('build') + .option('--no-optimization', 'Build the admin app without optimizing assets') + .description('Build the strapi admin app') + .action(getLocalScript('build-command')); // build-command dir to avoid problems with 'build' being commonly ignored +}; diff --git a/packages/core/strapi/lib/commands/configurationDump.js b/packages/core/strapi/lib/commands/actions/configuration/dump/action.js similarity index 96% rename from packages/core/strapi/lib/commands/configurationDump.js rename to packages/core/strapi/lib/commands/actions/configuration/dump/action.js index 4745b94282..7c6527e91b 100644 --- a/packages/core/strapi/lib/commands/configurationDump.js +++ b/packages/core/strapi/lib/commands/actions/configuration/dump/action.js @@ -1,7 +1,7 @@ 'use strict'; const fs = require('fs'); -const strapi = require('../index'); +const strapi = require('../../../../index'); const CHUNK_SIZE = 100; diff --git a/packages/core/strapi/lib/commands/actions/configuration/dump/command.js b/packages/core/strapi/lib/commands/actions/configuration/dump/command.js new file mode 100644 index 0000000000..8bf7f92552 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/configuration/dump/command.js @@ -0,0 +1,17 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi configuration:dump` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('configuration:dump') + .alias('config:dump') + .description('Dump configurations of your application') + .option('-f, --file ', 'Output file, default output is stdout') + .option('-p, --pretty', 'Format the output JSON with indentation and line breaks', false) + .action(getLocalScript('configuration/dump')); +}; diff --git a/packages/core/strapi/lib/commands/configurationRestore.js b/packages/core/strapi/lib/commands/actions/configuration/restore/action.js similarity index 98% rename from packages/core/strapi/lib/commands/configurationRestore.js rename to packages/core/strapi/lib/commands/actions/configuration/restore/action.js index 894c840d2d..fc416df741 100644 --- a/packages/core/strapi/lib/commands/configurationRestore.js +++ b/packages/core/strapi/lib/commands/actions/configuration/restore/action.js @@ -3,7 +3,7 @@ const fs = require('fs'); const _ = require('lodash'); -const strapi = require('../index'); +const strapi = require('../../../../index'); /** * Will restore configurations. It reads from a file or stdin diff --git a/packages/core/strapi/lib/commands/actions/configuration/restore/command.js b/packages/core/strapi/lib/commands/actions/configuration/restore/command.js new file mode 100644 index 0000000000..e12af7b040 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/configuration/restore/command.js @@ -0,0 +1,17 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi configuration:restore` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('configuration:restore') + .alias('config:restore') + .description('Restore configurations of your application') + .option('-f, --file ', 'Input file, default input is stdin') + .option('-s, --strategy ', 'Strategy name, one of: "replace", "merge", "keep"') + .action(getLocalScript('configuration/restore')); +}; diff --git a/packages/core/strapi/lib/commands/console.js b/packages/core/strapi/lib/commands/actions/console/action.js similarity index 87% rename from packages/core/strapi/lib/commands/console.js rename to packages/core/strapi/lib/commands/actions/console/action.js index 6c082e0b49..937faed82f 100644 --- a/packages/core/strapi/lib/commands/console.js +++ b/packages/core/strapi/lib/commands/actions/console/action.js @@ -2,7 +2,7 @@ const REPL = require('repl'); -const strapi = require('../index'); +const strapi = require('../../../index'); /** * `$ strapi console` @@ -14,7 +14,7 @@ module.exports = async () => { app.start().then(() => { const repl = REPL.start(app.config.info.name + ' > ' || 'strapi > '); // eslint-disable-line prefer-template - repl.on('exit', function (err) { + repl.on('exit', (err) => { if (err) { app.log.error(err); process.exit(1); diff --git a/packages/core/strapi/lib/commands/actions/console/command.js b/packages/core/strapi/lib/commands/actions/console/command.js new file mode 100644 index 0000000000..4ae9e0394b --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/console/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi console` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('console') + .description('Open the Strapi framework console') + .action(getLocalScript('console')); +}; diff --git a/packages/core/strapi/lib/commands/content-types/list.js b/packages/core/strapi/lib/commands/actions/content-types/list/action.js similarity index 85% rename from packages/core/strapi/lib/commands/content-types/list.js rename to packages/core/strapi/lib/commands/actions/content-types/list/action.js index f2757adc92..73064e597e 100644 --- a/packages/core/strapi/lib/commands/content-types/list.js +++ b/packages/core/strapi/lib/commands/actions/content-types/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/content-types/list/command.js b/packages/core/strapi/lib/commands/actions/content-types/list/command.js new file mode 100644 index 0000000000..54fb57e834 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/content-types/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi content-types:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('content-types:list') + .description('List all the application content-types') + .action(getLocalScript('content-types/list')); +}; diff --git a/packages/core/strapi/lib/commands/controllers/list.js b/packages/core/strapi/lib/commands/actions/controllers/list/action.js similarity index 85% rename from packages/core/strapi/lib/commands/controllers/list.js rename to packages/core/strapi/lib/commands/actions/controllers/list/action.js index b78799ed09..26877f4a7f 100644 --- a/packages/core/strapi/lib/commands/controllers/list.js +++ b/packages/core/strapi/lib/commands/actions/controllers/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/controllers/list/command.js b/packages/core/strapi/lib/commands/actions/controllers/list/command.js new file mode 100644 index 0000000000..59b639938c --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/controllers/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi controllers:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('controllers:list') + .description('List all the application controllers') + .action(getLocalScript('controllers/list')); +}; diff --git a/packages/core/strapi/lib/commands/develop.js b/packages/core/strapi/lib/commands/actions/develop/action.js similarity index 82% rename from packages/core/strapi/lib/commands/develop.js rename to packages/core/strapi/lib/commands/actions/develop/action.js index 5e48019adc..f9133858ef 100644 --- a/packages/core/strapi/lib/commands/develop.js +++ b/packages/core/strapi/lib/commands/actions/develop/action.js @@ -9,9 +9,9 @@ const { getOr } = require('lodash/fp'); const { joinBy } = require('@strapi/utils'); const tsUtils = require('@strapi/typescript-utils'); -const loadConfiguration = require('../core/app-configuration'); -const strapi = require('../index'); -const { buildTypeScript, buildAdmin } = require('./builders'); +const loadConfiguration = require('../../../core/app-configuration'); +const strapi = require('../../../index'); +const { buildTypeScript, buildAdmin } = require('../../builders'); /** * `$ strapi develop` @@ -106,12 +106,30 @@ const primaryProcess = async ({ distDir, appDir, build, isTSProject, watchAdmin, cluster.fork(); }; -const workerProcess = ({ appDir, distDir, watchAdmin, polling, isTSProject }) => { - const strapiInstance = strapi({ +const workerProcess = async ({ appDir, distDir, watchAdmin, polling, isTSProject }) => { + const strapiInstance = await strapi({ distDir, autoReload: true, serveAdminPanel: !watchAdmin, - }); + }).load(); + + /** + * TypeScript automatic type generation upon dev server restart + * Its implementation, configuration and behavior can change in future releases + * @experimental + */ + const shouldGenerateTypeScriptTypes = strapiInstance.config.get('typescript.autogenerate', false); + + if (shouldGenerateTypeScriptTypes) { + // This is run in an uncaught promise on purpose so that it doesn't block Strapi startup + // NOTE: We should probably add some configuration options to manage the file structure output or the verbosity level + tsUtils.generators.generateSchemasDefinitions({ + strapi: strapiInstance, + outDir: appDir, + verbose: false, + silent: true, + }); + } const adminWatchIgnoreFiles = strapiInstance.config.get('admin.watchIgnoreFiles', []); watchFileChanges({ @@ -179,6 +197,7 @@ function watchFileChanges({ appDir, strapiInstance, watchIgnoreFiles, polling }) '**/*.db*', '**/exports/**', '**/dist/**', + '**/*.d.ts', ...watchIgnoreFiles, ], }); diff --git a/packages/core/strapi/lib/commands/actions/develop/command.js b/packages/core/strapi/lib/commands/actions/develop/command.js new file mode 100644 index 0000000000..16e8ca7eaf --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/develop/command.js @@ -0,0 +1,19 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi develop` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('develop') + .alias('dev') + .option('--no-build', 'Disable build') + .option('--watch-admin', 'Enable watch', false) + .option('--polling', 'Watch for file changes in network directories', false) + .option('--browser ', 'Open the browser', true) + .description('Start your Strapi application in development mode') + .action(getLocalScript('develop')); +}; diff --git a/packages/core/strapi/lib/commands/__tests__/data-transfer/export.test.js b/packages/core/strapi/lib/commands/actions/export/__tests__/export.test.js similarity index 89% rename from packages/core/strapi/lib/commands/__tests__/data-transfer/export.test.js rename to packages/core/strapi/lib/commands/actions/export/__tests__/export.test.js index 43f4a8292c..42d013d411 100644 --- a/packages/core/strapi/lib/commands/__tests__/data-transfer/export.test.js +++ b/packages/core/strapi/lib/commands/actions/export/__tests__/export.test.js @@ -1,6 +1,6 @@ 'use strict'; -const { expectExit } = require('./shared/transfer.test.utils'); +const { expectExit } = require('../../../__tests__/commands.test.utils'); describe('Export', () => { const defaultFileName = 'defaultFilename'; @@ -21,6 +21,7 @@ describe('Export', () => { }, }, engine: { + ...jest.requireActual('@strapi/data-transfer').engine, errors: {}, createTransferEngine() { return { @@ -53,7 +54,7 @@ describe('Export', () => { jest.mock('@strapi/data-transfer', () => mockDataTransfer); - // mock utils + // command utils const mockUtils = { getTransferTelemetryPayload: jest.fn().mockReturnValue({}), loadersFactory: jest.fn().mockReturnValue({ updateLoader: jest.fn() }), @@ -76,7 +77,7 @@ describe('Export', () => { exitMessageText: jest.fn(), }; jest.mock( - '../../transfer/utils', + '../../../utils/data-transfer.js', () => { return mockUtils; }, @@ -90,7 +91,7 @@ describe('Export', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); // Now that everything is mocked, load the 'export' command - const exportCommand = require('../../transfer/export'); + const exportAction = require('../action'); beforeEach(() => { jest.clearAllMocks(); @@ -100,7 +101,7 @@ describe('Export', () => { const filename = 'test'; await expectExit(0, async () => { - await exportCommand({ file: filename }); + await exportAction({ file: filename }); }); expect(console.error).not.toHaveBeenCalled(); @@ -114,7 +115,7 @@ describe('Export', () => { it('uses default path if not provided by user', async () => { await expectExit(0, async () => { - await exportCommand({}); + await exportAction({}); }); expect(mockUtils.getDefaultExportName).toHaveBeenCalledTimes(1); @@ -128,7 +129,7 @@ describe('Export', () => { it('encrypts the output file if specified', async () => { const encrypt = true; await expectExit(0, async () => { - await exportCommand({ encrypt }); + await exportAction({ encrypt }); }); expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith( @@ -142,7 +143,7 @@ describe('Export', () => { const key = 'secret-key'; const encrypt = true; await expectExit(0, async () => { - await exportCommand({ encrypt, key }); + await exportAction({ encrypt, key }); }); expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith( @@ -154,7 +155,7 @@ describe('Export', () => { it('uses compress option', async () => { await expectExit(0, async () => { - await exportCommand({ compress: false }); + await exportAction({ compress: false }); }); expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith( @@ -163,7 +164,7 @@ describe('Export', () => { }) ); await expectExit(0, async () => { - await exportCommand({ compress: true }); + await exportAction({ compress: true }); }); expect(mockDataTransfer.file.providers.createLocalFileDestinationProvider).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/core/strapi/lib/commands/transfer/export.js b/packages/core/strapi/lib/commands/actions/export/action.js similarity index 98% rename from packages/core/strapi/lib/commands/transfer/export.js rename to packages/core/strapi/lib/commands/actions/export/action.js index f4f2a712d7..fba3db1a45 100644 --- a/packages/core/strapi/lib/commands/transfer/export.js +++ b/packages/core/strapi/lib/commands/actions/export/action.js @@ -25,8 +25,9 @@ const { exitMessageText, abortTransfer, getTransferTelemetryPayload, -} = require('./utils'); -const { exitWith } = require('../utils/helpers'); +} = require('../../utils/data-transfer'); +const { exitWith } = require('../../utils/helpers'); + /** * @typedef ExportCommandOptions Options given to the CLI import command * diff --git a/packages/core/strapi/lib/commands/actions/export/command.js b/packages/core/strapi/lib/commands/actions/export/command.js new file mode 100644 index 0000000000..254f198bab --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/export/command.js @@ -0,0 +1,45 @@ +'use strict'; + +const { Option } = require('commander'); +const { + excludeOption, + onlyOption, + throttleOption, + validateExcludeOnly, +} = require('../../utils/data-transfer'); +const { promptEncryptionKey } = require('../../utils/commander'); +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi export` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('export') + .description('Export data from Strapi to file') + .allowExcessArguments(false) + .addOption( + new Option('--no-encrypt', `Disables 'aes-128-ecb' encryption of the output file`).default( + true + ) + ) + .addOption( + new Option('--no-compress', 'Disables gzip compression of output file').default(true) + ) + .addOption( + new Option( + '-k, --key ', + 'Provide encryption key in command instead of using the prompt' + ) + ) + .addOption( + new Option('-f, --file ', 'name to use for exported file (without extensions)') + ) + .addOption(excludeOption) + .addOption(onlyOption) + .addOption(throttleOption) + .hook('preAction', validateExcludeOnly) + .hook('preAction', promptEncryptionKey) + .action(getLocalScript('export')); +}; diff --git a/packages/core/strapi/lib/commands/actions/generate/command.js b/packages/core/strapi/lib/commands/actions/generate/command.js new file mode 100644 index 0000000000..33ea49e8df --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/generate/command.js @@ -0,0 +1,18 @@ +'use strict'; + +const { assertCwdContainsStrapiProject } = require('../../utils/helpers'); + +/** + * `$ strapi generate` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command, argv }) => { + command + .command('generate') + .description('Launch the interactive API generator') + .action(() => { + assertCwdContainsStrapiProject('generate'); + argv.splice(2, 1); + require('@strapi/generators').runCLI(); + }); +}; diff --git a/packages/core/strapi/lib/commands/hooks/list.js b/packages/core/strapi/lib/commands/actions/hooks/list/action.js similarity index 84% rename from packages/core/strapi/lib/commands/hooks/list.js rename to packages/core/strapi/lib/commands/actions/hooks/list/action.js index 4813b0dc95..234a4d4257 100644 --- a/packages/core/strapi/lib/commands/hooks/list.js +++ b/packages/core/strapi/lib/commands/actions/hooks/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/hooks/list/command.js b/packages/core/strapi/lib/commands/actions/hooks/list/command.js new file mode 100644 index 0000000000..62eb119d78 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/hooks/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi hooks:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('hooks:list') + .description('List all the application hooks') + .action(getLocalScript('hooks/list')); +}; diff --git a/packages/core/strapi/lib/commands/__tests__/data-transfer/import.test.js b/packages/core/strapi/lib/commands/actions/import/__tests__/import.test.js similarity index 92% rename from packages/core/strapi/lib/commands/__tests__/data-transfer/import.test.js rename to packages/core/strapi/lib/commands/actions/import/__tests__/import.test.js index 6b2a726233..9356976f46 100644 --- a/packages/core/strapi/lib/commands/__tests__/data-transfer/import.test.js +++ b/packages/core/strapi/lib/commands/actions/import/__tests__/import.test.js @@ -7,7 +7,7 @@ const { engine: { DEFAULT_SCHEMA_STRATEGY, DEFAULT_VERSION_STRATEGY }, } = require('@strapi/data-transfer'); -const { expectExit } = require('./shared/transfer.test.utils'); +const { expectExit } = require('../../../__tests__/commands.test.utils'); const createTransferEngine = jest.fn(() => { return { @@ -53,6 +53,7 @@ describe('Import', () => { }, }, engine: { + ...jest.requireActual('@strapi/data-transfer').engine, DEFAULT_SCHEMA_STRATEGY, DEFAULT_VERSION_STRATEGY, createTransferEngine, @@ -61,7 +62,7 @@ describe('Import', () => { jest.mock('@strapi/data-transfer', () => mockDataTransfer); - // mock utils + // command utils const mockUtils = { getTransferTelemetryPayload: jest.fn().mockReturnValue({}), loadersFactory: jest.fn().mockReturnValue({ updateLoader: jest.fn() }), @@ -82,7 +83,7 @@ describe('Import', () => { exitMessageText: jest.fn(), }; jest.mock( - '../../transfer/utils', + '../../../utils/data-transfer.js', () => { return mockUtils; }, @@ -96,7 +97,7 @@ describe('Import', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); // Now that everything is mocked, load the 'import' command - const importCommand = require('../../transfer/import'); + const importAction = require('../action'); beforeEach(() => { jest.clearAllMocks(); @@ -112,7 +113,7 @@ describe('Import', () => { }; await expectExit(0, async () => { - await importCommand(options); + await importAction(options); }); // strapi options diff --git a/packages/core/strapi/lib/commands/transfer/import.js b/packages/core/strapi/lib/commands/actions/import/action.js similarity index 98% rename from packages/core/strapi/lib/commands/transfer/import.js rename to packages/core/strapi/lib/commands/actions/import/action.js index 173dd734fe..9a6095ca4c 100644 --- a/packages/core/strapi/lib/commands/transfer/import.js +++ b/packages/core/strapi/lib/commands/actions/import/action.js @@ -21,8 +21,8 @@ const { exitMessageText, abortTransfer, getTransferTelemetryPayload, -} = require('./utils'); -const { exitWith } = require('../utils/helpers'); +} = require('../../utils/data-transfer'); +const { exitWith } = require('../../utils/helpers'); /** * @typedef {import('@strapi/data-transfer/src/file/providers').ILocalFileSourceProviderOptions} ILocalFileSourceProviderOptions diff --git a/packages/core/strapi/lib/commands/actions/import/command.js b/packages/core/strapi/lib/commands/actions/import/command.js new file mode 100644 index 0000000000..5f7bd01f7e --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/import/command.js @@ -0,0 +1,97 @@ +'use strict'; + +const { Option } = require('commander'); +const path = require('path'); +const inquirer = require('inquirer'); +const { + excludeOption, + onlyOption, + throttleOption, + validateExcludeOnly, +} = require('../../utils/data-transfer'); +const { confirmMessage, forceOption } = require('../../utils/commander'); +const { getLocalScript, exitWith } = require('../../utils/helpers'); + +/** + * `$ strapi import` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('import') + .description('Import data from file to Strapi') + .allowExcessArguments(false) + .requiredOption( + '-f, --file ', + 'path and filename for the Strapi export file you want to import' + ) + .addOption( + new Option( + '-k, --key ', + 'Provide encryption key in command instead of using the prompt' + ) + ) + .addOption(forceOption) + .addOption(excludeOption) + .addOption(onlyOption) + .addOption(throttleOption) + .hook('preAction', validateExcludeOnly) + .hook('preAction', async (thisCommand) => { + const opts = thisCommand.opts(); + const ext = path.extname(String(opts.file)); + + // check extension to guess if we should prompt for key + if (ext === '.enc') { + if (!opts.key) { + const answers = await inquirer.prompt([ + { + type: 'password', + message: 'Please enter your decryption key', + name: 'key', + }, + ]); + if (!answers.key?.length) { + exitWith(1, 'No key entered, aborting import.'); + } + opts.key = answers.key; + } + } + }) + // set decrypt and decompress options based on filename + .hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + + const { extname, parse } = path; + + let file = opts.file; + + if (extname(file) === '.enc') { + file = parse(file).name; // trim the .enc extension + thisCommand.opts().decrypt = true; + } else { + thisCommand.opts().decrypt = false; + } + + if (extname(file) === '.gz') { + file = parse(file).name; // trim the .gz extension + thisCommand.opts().decompress = true; + } else { + thisCommand.opts().decompress = false; + } + + if (extname(file) !== '.tar') { + exitWith( + 1, + `The file '${opts.file}' does not appear to be a valid Strapi data file. It must have an extension ending in .tar[.gz][.enc]` + ); + } + }) + .hook( + 'preAction', + confirmMessage( + 'The import will delete all assets and data in your database. Are you sure you want to proceed?', + { failMessage: 'Import process aborted' } + ) + ) + .action(getLocalScript('import')); +}; diff --git a/packages/core/strapi/lib/commands/install.js b/packages/core/strapi/lib/commands/actions/install/action.js similarity index 95% rename from packages/core/strapi/lib/commands/install.js rename to packages/core/strapi/lib/commands/actions/install/action.js index b2c1ba7c68..3a2c9444d9 100644 --- a/packages/core/strapi/lib/commands/install.js +++ b/packages/core/strapi/lib/commands/actions/install/action.js @@ -4,7 +4,7 @@ const { join } = require('path'); const { existsSync } = require('fs-extra'); const ora = require('ora'); const execa = require('execa'); -const findPackagePath = require('../load/package-path'); +const findPackagePath = require('../../../load/package-path'); module.exports = async (plugins) => { const loader = ora(); diff --git a/packages/core/strapi/lib/commands/actions/install/command.js b/packages/core/strapi/lib/commands/actions/install/command.js new file mode 100644 index 0000000000..7968f4478a --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/install/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi install` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('install [plugins...]') + .description('Install a Strapi plugin') + .action(getLocalScript('install')); +}; diff --git a/packages/core/strapi/lib/commands/middlewares/list.js b/packages/core/strapi/lib/commands/actions/middlewares/list/action.js similarity index 85% rename from packages/core/strapi/lib/commands/middlewares/list.js rename to packages/core/strapi/lib/commands/actions/middlewares/list/action.js index d658792985..00cfe428dd 100644 --- a/packages/core/strapi/lib/commands/middlewares/list.js +++ b/packages/core/strapi/lib/commands/actions/middlewares/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/middlewares/list/command.js b/packages/core/strapi/lib/commands/actions/middlewares/list/command.js new file mode 100644 index 0000000000..76d0f0c563 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/middlewares/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi middlewares:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('middlewares:list') + .description('List all the application middlewares') + .action(getLocalScript('middlewares/list')); +}; diff --git a/packages/core/strapi/lib/commands/new.js b/packages/core/strapi/lib/commands/actions/new/action.js similarity index 82% rename from packages/core/strapi/lib/commands/new.js rename to packages/core/strapi/lib/commands/actions/new/action.js index 297b0a0fa2..f6e68ea546 100644 --- a/packages/core/strapi/lib/commands/new.js +++ b/packages/core/strapi/lib/commands/actions/new/action.js @@ -8,6 +8,6 @@ const { generateNewApp } = require('@strapi/generate-new'); * Generate a new Strapi application. */ -module.exports = function (...args) { +module.exports = (...args) => { return generateNewApp(...args); }; diff --git a/packages/core/strapi/lib/commands/actions/new/command.js b/packages/core/strapi/lib/commands/actions/new/command.js new file mode 100644 index 0000000000..92e231a631 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/new/command.js @@ -0,0 +1,35 @@ +'use strict'; + +const { yellow } = require('chalk'); + +/** + * `$ strapi new` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('new ') + .option('--no-run', 'Do not start the application after it is created') + .option('--use-npm', 'Force usage of npm instead of yarn to create the project') + .option('--debug', 'Display database connection errors') + .option('--quickstart', 'Create quickstart app') + .option('--dbclient ', 'Database client') + .option('--dbhost ', 'Database host') + .option('--dbport ', 'Database port') + .option('--dbname ', 'Database name') + .option('--dbusername ', 'Database username') + .option('--dbpassword ', 'Database password') + .option('--dbssl ', 'Database SSL') + .option('--dbfile ', 'Database file path for sqlite') + .option('--dbforce', 'Allow overwriting existing database content') + .option('-ts, --typescript', 'Create a typescript project') + .description('Create a new application') + .hook('preAction', () => { + console.warn( + yellow( + 'The `strapi new` command has been deprecated in v4 and will be removed in v5. `create-strapi-app` should be used to create a new Strapi project.' + ) + ); + }) + .action(require('./action')); +}; diff --git a/packages/core/strapi/lib/commands/policies/list.js b/packages/core/strapi/lib/commands/actions/policies/list/action.js similarity index 84% rename from packages/core/strapi/lib/commands/policies/list.js rename to packages/core/strapi/lib/commands/actions/policies/list/action.js index 8c4dcd6256..c4ace2cb51 100644 --- a/packages/core/strapi/lib/commands/policies/list.js +++ b/packages/core/strapi/lib/commands/actions/policies/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/policies/list/command.js b/packages/core/strapi/lib/commands/actions/policies/list/command.js new file mode 100644 index 0000000000..04242676f0 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/policies/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi policies:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('policies:list') + .description('List all the application policies') + .action(getLocalScript('policies/list')); +}; diff --git a/packages/core/strapi/lib/commands/report.js b/packages/core/strapi/lib/commands/actions/report/action.js similarity index 96% rename from packages/core/strapi/lib/commands/report.js rename to packages/core/strapi/lib/commands/actions/report/action.js index 970f566a5b..9a3bb7f941 100644 --- a/packages/core/strapi/lib/commands/report.js +++ b/packages/core/strapi/lib/commands/actions/report/action.js @@ -1,7 +1,7 @@ 'use strict'; const { EOL } = require('os'); -const strapi = require('../index'); +const strapi = require('../../../index'); module.exports = async ({ uuid, dependencies, all }) => { const config = { diff --git a/packages/core/strapi/lib/commands/actions/report/command.js b/packages/core/strapi/lib/commands/actions/report/command.js new file mode 100644 index 0000000000..4ad0e72891 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/report/command.js @@ -0,0 +1,17 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi report` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('report') + .description('Get system stats for debugging and submitting issues') + .option('-u, --uuid', 'Include Project UUID') + .option('-d, --dependencies', 'Include Project Dependencies') + .option('--all', 'Include All Information') + .action(getLocalScript('report')); +}; diff --git a/packages/core/strapi/lib/commands/routes/list.js b/packages/core/strapi/lib/commands/actions/routes/list/action.js similarity index 88% rename from packages/core/strapi/lib/commands/routes/list.js rename to packages/core/strapi/lib/commands/actions/routes/list/action.js index 933a77ba65..3c40460ddb 100644 --- a/packages/core/strapi/lib/commands/routes/list.js +++ b/packages/core/strapi/lib/commands/actions/routes/list/action.js @@ -4,9 +4,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); const { toUpper } = require('lodash/fp'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).load(); diff --git a/packages/core/strapi/lib/commands/actions/routes/list/command.js b/packages/core/strapi/lib/commands/actions/routes/list/command.js new file mode 100644 index 0000000000..cdf5f7e54b --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/routes/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi routes:list`` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('routes:list') + .description('List all the application routes') + .action(getLocalScript('routes/list')); +}; diff --git a/packages/core/strapi/lib/commands/services/list.js b/packages/core/strapi/lib/commands/actions/services/list/action.js similarity index 84% rename from packages/core/strapi/lib/commands/services/list.js rename to packages/core/strapi/lib/commands/actions/services/list/action.js index c9ace94ee5..77bc4182fc 100644 --- a/packages/core/strapi/lib/commands/services/list.js +++ b/packages/core/strapi/lib/commands/actions/services/list/action.js @@ -3,9 +3,9 @@ const CLITable = require('cli-table3'); const chalk = require('chalk'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function () { +module.exports = async () => { const appContext = await strapi.compile(); const app = await strapi(appContext).register(); diff --git a/packages/core/strapi/lib/commands/actions/services/list/command.js b/packages/core/strapi/lib/commands/actions/services/list/command.js new file mode 100644 index 0000000000..0f005818a6 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/services/list/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi services:list` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('services:list') + .description('List all the application services') + .action(getLocalScript('services/list')); +}; diff --git a/packages/core/strapi/lib/commands/start.js b/packages/core/strapi/lib/commands/actions/start/action.js similarity index 93% rename from packages/core/strapi/lib/commands/start.js rename to packages/core/strapi/lib/commands/actions/start/action.js index 829a7cf66e..ce0e47ab2f 100644 --- a/packages/core/strapi/lib/commands/start.js +++ b/packages/core/strapi/lib/commands/actions/start/action.js @@ -2,7 +2,7 @@ const fs = require('fs'); const tsUtils = require('@strapi/typescript-utils'); -const strapi = require('../index'); +const strapi = require('../../../index'); /** * `$ strapi start` diff --git a/packages/core/strapi/lib/commands/actions/start/command.js b/packages/core/strapi/lib/commands/actions/start/command.js new file mode 100644 index 0000000000..e2c3c1029b --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/start/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi start` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('start') + .description('Start your Strapi application') + .action(getLocalScript('start')); +}; diff --git a/packages/core/strapi/lib/commands/opt-out-telemetry.js b/packages/core/strapi/lib/commands/actions/telemetry/disable/action.js similarity index 97% rename from packages/core/strapi/lib/commands/opt-out-telemetry.js rename to packages/core/strapi/lib/commands/actions/telemetry/disable/action.js index 3ec7dc1f24..26c3130834 100644 --- a/packages/core/strapi/lib/commands/opt-out-telemetry.js +++ b/packages/core/strapi/lib/commands/actions/telemetry/disable/action.js @@ -4,7 +4,7 @@ const { resolve } = require('path'); const fse = require('fs-extra'); const chalk = require('chalk'); const fetch = require('node-fetch'); -const machineID = require('../utils/machine-id'); +const machineID = require('../../../../utils/machine-id'); const readPackageJSON = async (path) => { try { diff --git a/packages/core/strapi/lib/commands/actions/telemetry/disable/command.js b/packages/core/strapi/lib/commands/actions/telemetry/disable/command.js new file mode 100644 index 0000000000..856ee89498 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/telemetry/disable/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi telemetry:disable` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('telemetry:disable') + .description('Disable anonymous telemetry and metadata sending to Strapi analytics') + .action(getLocalScript('telemetry/disable')); +}; diff --git a/packages/core/strapi/lib/commands/opt-in-telemetry.js b/packages/core/strapi/lib/commands/actions/telemetry/enable/action.js similarity index 92% rename from packages/core/strapi/lib/commands/opt-in-telemetry.js rename to packages/core/strapi/lib/commands/actions/telemetry/enable/action.js index f562de6512..09be27726e 100644 --- a/packages/core/strapi/lib/commands/opt-in-telemetry.js +++ b/packages/core/strapi/lib/commands/actions/telemetry/enable/action.js @@ -4,8 +4,8 @@ const { resolve } = require('path'); const fse = require('fs-extra'); const chalk = require('chalk'); const fetch = require('node-fetch'); -const { v4: uuidv4 } = require('uuid'); -const machineID = require('../utils/machine-id'); +const { randomUUID } = require('crypto'); +const machineID = require('../../../../utils/machine-id'); const readPackageJSON = async (path) => { try { @@ -36,7 +36,7 @@ const generateNewPackageJSON = (packageObj) => { return { ...packageObj, strapi: { - uuid: uuidv4(), + uuid: randomUUID(), telemetryDisabled: false, }, }; @@ -45,7 +45,7 @@ const generateNewPackageJSON = (packageObj) => { ...packageObj, strapi: { ...packageObj.strapi, - uuid: packageObj.strapi.uuid ? packageObj.strapi.uuid : uuidv4(), + uuid: packageObj.strapi.uuid ? packageObj.strapi.uuid : randomUUID(), telemetryDisabled: false, }, }; diff --git a/packages/core/strapi/lib/commands/actions/telemetry/enable/command.js b/packages/core/strapi/lib/commands/actions/telemetry/enable/command.js new file mode 100644 index 0000000000..8bbbc92154 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/telemetry/enable/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi telemetry:enable` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('telemetry:enable') + .description('Enable anonymous telemetry and metadata sending to Strapi analytics') + .action(getLocalScript('telemetry/enable')); +}; diff --git a/packages/core/strapi/lib/commands/__tests__/generate-template.test.js b/packages/core/strapi/lib/commands/actions/templates/generate/__tests__/templates.generate.js similarity index 97% rename from packages/core/strapi/lib/commands/__tests__/generate-template.test.js rename to packages/core/strapi/lib/commands/actions/templates/generate/__tests__/templates.generate.js index 103bf326c8..71ccc00dfe 100644 --- a/packages/core/strapi/lib/commands/__tests__/generate-template.test.js +++ b/packages/core/strapi/lib/commands/actions/templates/generate/__tests__/templates.generate.js @@ -11,9 +11,9 @@ const { resolve, join } = require('path'); const fse = require('fs-extra'); const inquirer = require('inquirer'); -const exportTemplate = require('../generate-template'); +const exportTemplate = require('../action'); -describe('generate:template command', () => { +describe('templates:generate command', () => { beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); jest.clearAllMocks(); diff --git a/packages/core/strapi/lib/commands/generate-template.js b/packages/core/strapi/lib/commands/actions/templates/generate/action.js similarity index 100% rename from packages/core/strapi/lib/commands/generate-template.js rename to packages/core/strapi/lib/commands/actions/templates/generate/action.js diff --git a/packages/core/strapi/lib/commands/actions/templates/generate/command.js b/packages/core/strapi/lib/commands/actions/templates/generate/command.js new file mode 100644 index 0000000000..20ced14390 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/templates/generate/command.js @@ -0,0 +1,14 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + *`$ strapi templates:generate ` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('templates:generate ') + .description('Generate template from Strapi project') + .action(getLocalScript('templates/generate')); +}; diff --git a/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js b/packages/core/strapi/lib/commands/actions/transfer/__tests__/transfer.test.js similarity index 86% rename from packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js rename to packages/core/strapi/lib/commands/actions/transfer/__tests__/transfer.test.js index c05c60f947..ca7d6d8cfc 100644 --- a/packages/core/strapi/lib/commands/__tests__/data-transfer/transfer.test.js +++ b/packages/core/strapi/lib/commands/actions/transfer/__tests__/transfer.test.js @@ -1,9 +1,9 @@ 'use strict'; -const { expectExit } = require('./shared/transfer.test.utils'); +const { expectExit } = require('../../../__tests__/commands.test.utils'); describe('Transfer', () => { - // mock utils + // command utils const mockUtils = { getTransferTelemetryPayload: jest.fn().mockReturnValue({}), loadersFactory: jest.fn().mockReturnValue({ updateLoader: jest.fn() }), @@ -26,7 +26,7 @@ describe('Transfer', () => { exitMessageText: jest.fn(), }; jest.mock( - '../../transfer/utils', + '../../../utils/data-transfer.js', () => { return mockUtils; }, @@ -46,6 +46,7 @@ describe('Transfer', () => { }, }, engine: { + ...jest.requireActual('@strapi/data-transfer').engine, createTransferEngine() { return { transfer: jest.fn(() => { @@ -72,7 +73,7 @@ describe('Transfer', () => { jest.mock('@strapi/data-transfer', () => mockDataTransfer); - const transferCommand = require('../../transfer/transfer'); + const transferAction = require('../action'); // console spies jest.spyOn(console, 'log').mockImplementation(() => {}); @@ -91,7 +92,7 @@ describe('Transfer', () => { it('exits with error when no --to or --from is provided', async () => { await expectExit(1, async () => { - await transferCommand({ from: undefined, to: undefined }); + await transferAction({ from: undefined, to: undefined }); }); expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/one source/i)); @@ -103,7 +104,7 @@ describe('Transfer', () => { it('exits with error when both --to and --from are provided', async () => { await expectExit(1, async () => { - await transferCommand({ from: sourceUrl, to: destinationUrl }); + await transferAction({ from: sourceUrl, to: destinationUrl }); }); expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/one source/i)); @@ -116,7 +117,7 @@ describe('Transfer', () => { describe('--to', () => { it('exits with error when auth is not provided', async () => { await expectExit(1, async () => { - await transferCommand({ from: undefined, to: destinationUrl }); + await transferAction({ from: undefined, to: destinationUrl }); }); expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/missing token/i)); @@ -128,7 +129,7 @@ describe('Transfer', () => { it('uses destination url and token provided by user', async () => { await expectExit(0, async () => { - await transferCommand({ from: undefined, to: destinationUrl, toToken: destinationToken }); + await transferAction({ from: undefined, to: destinationUrl, toToken: destinationToken }); }); expect(console.error).not.toHaveBeenCalled(); @@ -147,7 +148,7 @@ describe('Transfer', () => { it('uses local Strapi source when from is not specified', async () => { await expectExit(0, async () => { - await transferCommand({ from: undefined, to: destinationUrl, toToken: destinationToken }); + await transferAction({ from: undefined, to: destinationUrl, toToken: destinationToken }); }); expect(console.error).not.toHaveBeenCalled(); @@ -162,7 +163,7 @@ describe('Transfer', () => { it('uses restore as the default strategy', async () => { await expectExit(0, async () => { - await transferCommand({ from: undefined, to: destinationUrl, toToken: destinationToken }); + await transferAction({ from: undefined, to: destinationUrl, toToken: destinationToken }); }); expect(console.error).not.toHaveBeenCalled(); diff --git a/packages/core/strapi/lib/commands/transfer/transfer.js b/packages/core/strapi/lib/commands/actions/transfer/action.js similarity index 98% rename from packages/core/strapi/lib/commands/transfer/transfer.js rename to packages/core/strapi/lib/commands/actions/transfer/action.js index a59b5a7bcf..60c4b04ea4 100644 --- a/packages/core/strapi/lib/commands/transfer/transfer.js +++ b/packages/core/strapi/lib/commands/actions/transfer/action.js @@ -22,8 +22,8 @@ const { exitMessageText, abortTransfer, getTransferTelemetryPayload, -} = require('./utils'); -const { exitWith } = require('../utils/helpers'); +} = require('../../utils/data-transfer'); +const { exitWith } = require('../../utils/helpers'); /** * @typedef TransferCommandOptions Options given to the CLI transfer command diff --git a/packages/core/strapi/lib/commands/actions/transfer/command.js b/packages/core/strapi/lib/commands/actions/transfer/command.js new file mode 100644 index 0000000000..57fbe1b2bb --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/transfer/command.js @@ -0,0 +1,115 @@ +'use strict'; + +const inquirer = require('inquirer'); +const { Option } = require('commander'); +const { confirmMessage, forceOption, parseURL } = require('../../utils/commander'); +const { + getLocalScript, + exitWith, + assertUrlHasProtocol, + ifOptions, +} = require('../../utils/helpers'); +const { + excludeOption, + onlyOption, + throttleOption, + validateExcludeOnly, +} = require('../../utils/data-transfer'); + +/** + * `$ strapi transfer` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('transfer') + .description('Transfer data from one source to another') + .allowExcessArguments(false) + .addOption( + new Option( + '--from ', + `URL of the remote Strapi instance to get data from` + ).argParser(parseURL) + ) + .addOption(new Option('--from-token ', `Transfer token for the remote Strapi source`)) + .addOption( + new Option( + '--to ', + `URL of the remote Strapi instance to send data to` + ).argParser(parseURL) + ) + .addOption(new Option('--to-token ', `Transfer token for the remote Strapi destination`)) + .addOption(forceOption) + .addOption(excludeOption) + .addOption(onlyOption) + .addOption(throttleOption) + .hook('preAction', validateExcludeOnly) + .hook( + 'preAction', + ifOptions( + (opts) => !(opts.from || opts.to) || (opts.from && opts.to), + () => + exitWith( + 1, + 'Exactly one remote source (from) or destination (to) option must be provided' + ) + ) + ) + // If --from is used, validate the URL and token + .hook( + 'preAction', + ifOptions( + (opts) => opts.from, + async (thisCommand) => { + assertUrlHasProtocol(thisCommand.opts().from, ['https:', 'http:']); + if (!thisCommand.opts().fromToken) { + const answers = await inquirer.prompt([ + { + type: 'password', + message: 'Please enter your transfer token for the remote Strapi source', + name: 'fromToken', + }, + ]); + if (!answers.fromToken?.length) { + exitWith(1, 'No token provided for remote source, aborting transfer.'); + } + thisCommand.opts().fromToken = answers.fromToken; + } + + await confirmMessage( + 'The transfer will delete all the local Strapi assets and its database. Are you sure you want to proceed?', + { failMessage: 'Transfer process aborted' } + )(thisCommand); + } + ) + ) + // If --to is used, validate the URL, token, and confirm restore + .hook( + 'preAction', + ifOptions( + (opts) => opts.to, + async (thisCommand) => { + assertUrlHasProtocol(thisCommand.opts().to, ['https:', 'http:']); + if (!thisCommand.opts().toToken) { + const answers = await inquirer.prompt([ + { + type: 'password', + message: 'Please enter your transfer token for the remote Strapi destination', + name: 'toToken', + }, + ]); + if (!answers.toToken?.length) { + exitWith(1, 'No token provided for remote destination, aborting transfer.'); + } + thisCommand.opts().toToken = answers.toToken; + } + + await confirmMessage( + 'The transfer will delete all the remote Strapi assets and its database. Are you sure you want to proceed?', + { failMessage: 'Transfer process aborted' } + )(thisCommand); + } + ) + ) + .action(getLocalScript('transfer')); +}; diff --git a/packages/core/strapi/lib/commands/ts/generate-types.js b/packages/core/strapi/lib/commands/actions/ts/generate-types/action.js similarity index 82% rename from packages/core/strapi/lib/commands/ts/generate-types.js rename to packages/core/strapi/lib/commands/actions/ts/generate-types/action.js index 09c678e2e1..d16fa5a71c 100644 --- a/packages/core/strapi/lib/commands/ts/generate-types.js +++ b/packages/core/strapi/lib/commands/actions/ts/generate-types/action.js @@ -2,9 +2,9 @@ const tsUtils = require('@strapi/typescript-utils'); -const strapi = require('../../index'); +const strapi = require('../../../../index'); -module.exports = async function ({ outDir, file, verbose, silent }) { +module.exports = async ({ outDir, file, verbose, silent }) => { if (verbose && silent) { console.error('You cannot enable verbose and silent flags at the same time, exiting...'); process.exit(1); diff --git a/packages/core/strapi/lib/commands/actions/ts/generate-types/command.js b/packages/core/strapi/lib/commands/actions/ts/generate-types/command.js new file mode 100644 index 0000000000..3a4c151a64 --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/ts/generate-types/command.js @@ -0,0 +1,21 @@ +'use strict'; + +const { getLocalScript } = require('../../../utils/helpers'); + +/** + * `$ strapi ts:generate-types` + * @param {import('../../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('ts:generate-types') + .description(`Generate TypeScript typings for your schemas`) + .option( + '-o, --out-dir ', + 'Specify a relative directory in which the schemas definitions will be generated' + ) + .option('-f, --file ', 'Specify a filename to store the schemas definitions') + .option('--verbose', `Display more information about the types generation`, false) + .option('-s, --silent', `Run the generation silently, without any output`, false) + .action(getLocalScript('ts/generate-types')); +}; diff --git a/packages/core/strapi/lib/commands/uninstall.js b/packages/core/strapi/lib/commands/actions/uninstall/action.js similarity index 96% rename from packages/core/strapi/lib/commands/uninstall.js rename to packages/core/strapi/lib/commands/actions/uninstall/action.js index 0c5e34d547..d8a395bb6a 100644 --- a/packages/core/strapi/lib/commands/uninstall.js +++ b/packages/core/strapi/lib/commands/actions/uninstall/action.js @@ -5,7 +5,7 @@ const { existsSync, removeSync } = require('fs-extra'); const ora = require('ora'); const execa = require('execa'); const inquirer = require('inquirer'); -const findPackagePath = require('../load/package-path'); +const findPackagePath = require('../../../load/package-path'); module.exports = async (plugins, { deleteFiles }) => { const answers = await inquirer.prompt([ diff --git a/packages/core/strapi/lib/commands/actions/uninstall/command.js b/packages/core/strapi/lib/commands/actions/uninstall/command.js new file mode 100644 index 0000000000..711a28f31e --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/uninstall/command.js @@ -0,0 +1,15 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi uninstall` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('uninstall [plugins...]') + .description('Uninstall a Strapi plugin') + .option('-d, --delete-files', 'Delete files', false) + .action(getLocalScript('uninstall')); +}; diff --git a/packages/core/strapi/lib/commands/actions/version/command.js b/packages/core/strapi/lib/commands/actions/version/command.js new file mode 100644 index 0000000000..da661901fd --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/version/command.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * `$ strapi version` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + // load the Strapi package.json to get version and other information + const packageJSON = require('../../../../package.json'); + + command.version(packageJSON.version, '-v, --version', 'Output the version number'); + command + .command('version') + .description('Output the version of Strapi') + .action(() => { + process.stdout.write(`${packageJSON.version}\n`); + process.exit(0); + }); +}; diff --git a/packages/core/strapi/lib/commands/watchAdmin.js b/packages/core/strapi/lib/commands/actions/watch-admin/action.js similarity index 79% rename from packages/core/strapi/lib/commands/watchAdmin.js rename to packages/core/strapi/lib/commands/actions/watch-admin/action.js index a885cf901e..4edae9c7d5 100644 --- a/packages/core/strapi/lib/commands/watchAdmin.js +++ b/packages/core/strapi/lib/commands/actions/watch-admin/action.js @@ -3,11 +3,11 @@ const strapiAdmin = require('@strapi/admin'); const { getConfigUrls, getAbsoluteServerUrl } = require('@strapi/utils'); -const getEnabledPlugins = require('../core/loaders/plugins/get-enabled-plugins'); -const addSlash = require('../utils/addSlash'); -const strapi = require('../index'); +const getEnabledPlugins = require('../../../core/loaders/plugins/get-enabled-plugins'); +const addSlash = require('../../../utils/addSlash'); +const strapi = require('../../../index'); -module.exports = async function ({ browser }) { +module.exports = async ({ browser }) => { const appContext = await strapi.compile(); const strapiInstance = strapi({ diff --git a/packages/core/strapi/lib/commands/actions/watch-admin/command.js b/packages/core/strapi/lib/commands/actions/watch-admin/command.js new file mode 100644 index 0000000000..df84ba2cce --- /dev/null +++ b/packages/core/strapi/lib/commands/actions/watch-admin/command.js @@ -0,0 +1,15 @@ +'use strict'; + +const { getLocalScript } = require('../../utils/helpers'); + +/** + * `$ strapi watch-admin` + * @param {import('../../../types/core/commands').AddCommandOptions} options + */ +module.exports = ({ command }) => { + command + .command('watch-admin') + .option('--browser ', 'Open the browser', true) + .description('Start the admin development server') + .action(getLocalScript('watch-admin')); +}; diff --git a/packages/core/strapi/lib/commands/index.js b/packages/core/strapi/lib/commands/index.js new file mode 100644 index 0000000000..6222118112 --- /dev/null +++ b/packages/core/strapi/lib/commands/index.js @@ -0,0 +1,66 @@ +'use strict'; + +const { Command } = require('commander'); + +const strapiCommands = { + 'admin/create-user': require('./actions/admin/create-user/command'), + 'admin/reset-user-password': require('./actions/admin/reset-user-password/command'), + build: require('./actions/build-command/command'), // in 'build-command' to avoid problems with 'build' being commonly ignored + 'configuration/dump': require('./actions/configuration/dump/command'), + 'configuration/restore': require('./actions/configuration/restore/command'), + console: require('./actions/console/command'), + 'content-types/list': require('./actions/content-types/list/command'), + 'controllers/list': require('./actions/controllers/list/command'), + develop: require('./actions/develop/command'), + export: require('./actions/export/command'), + generate: require('./actions/generate/command'), + 'hooks/list': require('./actions/hooks/list/command'), + import: require('./actions/import/command'), + install: require('./actions/install/command'), + 'middlewares/list': require('./actions/middlewares/list/command'), + new: require('./actions/new/command'), + 'policies/list': require('./actions/policies/list/command'), + report: require('./actions/report/command'), + 'routes/list': require('./actions/routes/list/command'), + 'services/list': require('./actions/services/list/command'), + start: require('./actions/start/command'), + 'telemetry/disable': require('./actions/telemetry/disable/command'), + 'telemetry/enable': require('./actions/telemetry/enable/command'), + 'templates/generate': require('./actions/templates/generate/command'), + transfer: require('./actions/transfer/command'), + 'ts/generate-types': require('./actions/ts/generate-types/command'), + uninstall: require('./actions/uninstall/command'), + version: require('./actions/version/command'), + 'watch-admin': require('./actions/watch-admin/command'), +}; + +const buildStrapiCommand = (argv, command = new Command()) => { + // Initial program setup + command.storeOptionsAsProperties(false).allowUnknownOption(true); + + // Help command + command.helpOption('-h, --help', 'Display help for command'); + command.addHelpCommand('help [command]', 'Display help for command'); + + // Load all commands + Object.keys(strapiCommands).forEach((name) => { + try { + // Add this command to the Commander command object + strapiCommands[name]({ command, argv }); + } catch (e) { + console.error(`Failed to load command ${name}`, e); + } + }); + + return command; +}; + +const runStrapiCommand = async (argv = process.argv, command = new Command()) => { + await buildStrapiCommand(argv, command).parseAsync(argv); +}; + +module.exports = { + runStrapiCommand, + buildStrapiCommand, + strapiCommands, +}; diff --git a/packages/core/strapi/lib/commands/transfer/utils.js b/packages/core/strapi/lib/commands/utils/data-transfer.js similarity index 98% rename from packages/core/strapi/lib/commands/transfer/utils.js rename to packages/core/strapi/lib/commands/utils/data-transfer.js index 54acf1dad7..bab089da3e 100644 --- a/packages/core/strapi/lib/commands/transfer/utils.js +++ b/packages/core/strapi/lib/commands/utils/data-transfer.js @@ -12,9 +12,9 @@ const { createLogger, } = require('@strapi/logger'); const ora = require('ora'); -const { readableBytes, exitWith } = require('../utils/helpers'); +const { readableBytes, exitWith } = require('./helpers'); const strapi = require('../../index'); -const { getParseListWithChoices, parseInteger } = require('../utils/commander'); +const { getParseListWithChoices, parseInteger } = require('./commander'); const exitMessageText = (process, error = false) => { const processCapitalized = process[0].toUpperCase() + process.slice(1); diff --git a/packages/core/strapi/lib/commands/utils/helpers.js b/packages/core/strapi/lib/commands/utils/helpers.js index 6c60d40efe..c106fccf7b 100644 --- a/packages/core/strapi/lib/commands/utils/helpers.js +++ b/packages/core/strapi/lib/commands/utils/helpers.js @@ -4,8 +4,10 @@ * Helper functions for the Strapi CLI */ -const chalk = require('chalk'); +const { yellow, red, green } = require('chalk'); const { isString, isArray } = require('lodash/fp'); +const resolveCwd = require('resolve-cwd'); +const { has } = require('lodash/fp'); const bytesPerKb = 1024; const sizes = ['B ', 'KB', 'MB', 'GB', 'TB', 'PB']; @@ -46,9 +48,9 @@ const exitWith = (code, message = undefined, options = {}) => { const log = (message) => { if (code === 0) { - logger.log(chalk.green(message)); + logger.log(green(message)); } else { - logger.error(chalk.red(message)); + logger.error(red(message)); } }; @@ -107,9 +109,58 @@ const ifOptions = (conditionCallback, isMetCallback = () => {}, isNotMetCallback }; }; +const assertCwdContainsStrapiProject = (name) => { + const logErrorAndExit = () => { + console.log( + `You need to run ${yellow( + `strapi ${name}` + )} in a Strapi project. Make sure you are in the right directory.` + ); + process.exit(1); + }; + + try { + const pkgJSON = require(`${process.cwd()}/package.json`); + if (!has('dependencies.@strapi/strapi', pkgJSON)) { + logErrorAndExit(name); + } + } catch (err) { + logErrorAndExit(name); + } +}; + +const getLocalScript = + (name) => + (...args) => { + assertCwdContainsStrapiProject(name); + + const cmdPath = resolveCwd.silent(`@strapi/strapi/lib/commands/actions/${name}/action`); + if (!cmdPath) { + console.log( + `Error loading the local ${yellow( + name + )} command. Strapi might not be installed in your "node_modules". You may need to run "yarn install".` + ); + process.exit(1); + } + + const script = require(cmdPath); + + Promise.resolve() + .then(() => { + return script(...args); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); + }; + module.exports = { exitWith, assertUrlHasProtocol, ifOptions, readableBytes, + getLocalScript, + assertCwdContainsStrapiProject, }; diff --git a/packages/core/strapi/lib/core/loaders/apis.js b/packages/core/strapi/lib/core/loaders/apis.js index 83459f22e5..bceabd2396 100644 --- a/packages/core/strapi/lib/core/loaders/apis.js +++ b/packages/core/strapi/lib/core/loaders/apis.js @@ -5,6 +5,7 @@ const { existsSync } = require('fs-extra'); const _ = require('lodash'); const fse = require('fs-extra'); const { isKebabCase, importDefault } = require('@strapi/utils'); +const { isEmpty } = require('lodash/fp'); const DEFAULT_CONTENT_TYPE = { schema: {}, @@ -115,6 +116,10 @@ const loadContentTypes = async (dir) => { const contentTypeName = normalizeName(fd.name); const contentType = await loadDir(join(dir, fd.name)); + if (isEmpty(contentType) || isEmpty(contentType.schema)) { + throw new Error(`Could not load content type found at ${dir}`); + } + contentTypes[normalizeName(contentTypeName)] = _.defaults(contentType, DEFAULT_CONTENT_TYPE); } diff --git a/packages/core/strapi/lib/services/metrics/__tests__/index.test.js b/packages/core/strapi/lib/services/metrics/__tests__/index.test.js index 93c7721ba9..0438430f67 100644 --- a/packages/core/strapi/lib/services/metrics/__tests__/index.test.js +++ b/packages/core/strapi/lib/services/metrics/__tests__/index.test.js @@ -3,10 +3,14 @@ jest.mock('node-fetch', () => jest.fn(() => Promise.resolve())); const { get } = require('lodash/fp'); -const fetch = require('node-fetch'); const metrics = require('../index'); +const fetch = jest.fn(() => Promise.resolve()); + describe('metrics', () => { + beforeEach(() => { + fetch.mockClear(); + }); test('Initializes a middleware', () => { const use = jest.fn(); @@ -32,6 +36,7 @@ describe('metrics', () => { requestContext: { get: jest.fn(() => ({})), }, + fetch, }); metricsInstance.register(); @@ -66,6 +71,7 @@ describe('metrics', () => { requestContext: { get: jest.fn(() => ({})), }, + fetch, }); metricsInstance.register(); @@ -98,6 +104,7 @@ describe('metrics', () => { requestContext: { get: jest.fn(() => ({})), }, + fetch, }); send('someEvent'); @@ -140,6 +147,7 @@ describe('metrics', () => { requestContext: { get: jest.fn(() => ({})), }, + fetch, }); send('someEvent'); diff --git a/packages/core/strapi/lib/services/metrics/sender.js b/packages/core/strapi/lib/services/metrics/sender.js index e8735c2b7f..134f10c07d 100644 --- a/packages/core/strapi/lib/services/metrics/sender.js +++ b/packages/core/strapi/lib/services/metrics/sender.js @@ -4,7 +4,6 @@ const os = require('os'); const path = require('path'); const _ = require('lodash'); const isDocker = require('is-docker'); -const fetch = require('node-fetch'); const ciEnv = require('ci-info'); const { isUsingTypeScriptSync } = require('@strapi/typescript-utils'); const { env } = require('@strapi/utils'); @@ -82,7 +81,7 @@ module.exports = (strapi) => { }; try { - const res = await fetch(`${ANALYTICS_URI}/api/v2/track`, reqParams); + const res = await strapi.fetch(`${ANALYTICS_URI}/api/v2/track`, reqParams); return res.ok; } catch (err) { return false; diff --git a/packages/core/strapi/lib/services/webhook-runner.js b/packages/core/strapi/lib/services/webhook-runner.js index 77b6477f92..05d97f1a46 100644 --- a/packages/core/strapi/lib/services/webhook-runner.js +++ b/packages/core/strapi/lib/services/webhook-runner.js @@ -6,7 +6,6 @@ const debug = require('debug')('strapi:webhook'); const _ = require('lodash'); -const fetch = require('node-fetch'); const WorkerQueue = require('./worker-queue'); @@ -15,12 +14,13 @@ const defaultConfiguration = { }; class WebhookRunner { - constructor({ eventHub, logger, configuration = {} }) { - debug('Initialized webhook runer'); + constructor({ eventHub, logger, configuration = {}, fetch }) { + debug('Initialized webhook runner'); this.eventHub = eventHub; this.logger = logger; this.webhooksMap = new Map(); this.listeners = new Map(); + this.fetch = fetch; if (typeof configuration !== 'object') { throw new Error( @@ -76,7 +76,7 @@ class WebhookRunner { run(webhook, event, info = {}) { const { url, headers } = webhook; - return fetch(url, { + return this.fetch(url, { method: 'post', body: JSON.stringify({ event, diff --git a/packages/core/strapi/lib/types/core/commands/index.d.ts b/packages/core/strapi/lib/types/core/commands/index.d.ts new file mode 100644 index 0000000000..7a1dd750ef --- /dev/null +++ b/packages/core/strapi/lib/types/core/commands/index.d.ts @@ -0,0 +1,6 @@ +import { Command } from 'commander'; + +export type AddCommandOptions = { + command: Command; + argv: Record; +}; diff --git a/packages/core/strapi/lib/utils/fetch.js b/packages/core/strapi/lib/utils/fetch.js new file mode 100644 index 0000000000..3d4596cebd --- /dev/null +++ b/packages/core/strapi/lib/utils/fetch.js @@ -0,0 +1,23 @@ +'use strict'; + +const nodeFetch = require('node-fetch'); +const HttpsProxyAgent = require('https-proxy-agent'); + +function createStrapiFetch(strapi) { + function fetch(url, options) { + return nodeFetch(url, { + ...(fetch.agent ? { agent: fetch.agent } : {}), + ...options, + }); + } + + const { globalProxy: proxy } = strapi.config.get('server'); + + if (proxy) { + fetch.agent = new HttpsProxyAgent(proxy); + } + + return fetch; +} + +module.exports = createStrapiFetch; diff --git a/packages/core/strapi/lib/utils/machine-id.js b/packages/core/strapi/lib/utils/machine-id.js index a5557a487e..eb22f90c79 100644 --- a/packages/core/strapi/lib/utils/machine-id.js +++ b/packages/core/strapi/lib/utils/machine-id.js @@ -1,14 +1,14 @@ 'use strict'; const { machineIdSync } = require('node-machine-id'); -const { v4: uuidv4 } = require('uuid'); +const { randomUUID } = require('crypto'); module.exports = () => { try { const deviceId = machineIdSync(); return deviceId; } catch (error) { - const deviceId = uuidv4(); + const deviceId = randomUUID(); return deviceId; } }; diff --git a/packages/core/strapi/package.json b/packages/core/strapi/package.json index 1b2e354976..d3e79ff117 100644 --- a/packages/core/strapi/package.json +++ b/packages/core/strapi/package.json @@ -1,6 +1,6 @@ { "name": "@strapi/strapi", - "version": "4.9.2", + "version": "4.10.1", "description": "An open source headless CMS solution to create and manage your own API. It provides a powerful dashboard and features to make your life easier. Databases supported: MySQL, MariaDB, PostgreSQL, SQLite", "keywords": [ "strapi", @@ -81,19 +81,19 @@ "dependencies": { "@koa/cors": "3.4.3", "@koa/router": "10.1.1", - "@strapi/admin": "4.9.2", - "@strapi/data-transfer": "4.9.2", - "@strapi/database": "4.9.2", - "@strapi/generate-new": "4.9.2", - "@strapi/generators": "4.9.2", - "@strapi/logger": "4.9.2", - "@strapi/permissions": "4.9.2", - "@strapi/plugin-content-manager": "4.9.2", - "@strapi/plugin-content-type-builder": "4.9.2", - "@strapi/plugin-email": "4.9.2", - "@strapi/plugin-upload": "4.9.2", - "@strapi/typescript-utils": "4.9.2", - "@strapi/utils": "4.9.2", + "@strapi/admin": "4.10.1", + "@strapi/data-transfer": "4.10.1", + "@strapi/database": "4.10.1", + "@strapi/generate-new": "4.10.1", + "@strapi/generators": "4.10.1", + "@strapi/logger": "4.10.1", + "@strapi/permissions": "4.10.1", + "@strapi/plugin-content-manager": "4.10.1", + "@strapi/plugin-content-type-builder": "4.10.1", + "@strapi/plugin-email": "4.10.1", + "@strapi/plugin-upload": "4.10.1", + "@strapi/typescript-utils": "4.10.1", + "@strapi/utils": "4.10.1", "bcryptjs": "2.4.3", "boxen": "5.1.2", "chalk": "4.1.2", @@ -109,6 +109,7 @@ "fs-extra": "10.0.0", "glob": "7.2.0", "http-errors": "1.8.1", + "https-proxy-agent": "5.0.1", "inquirer": "8.2.5", "is-docker": "2.2.1", "koa": "2.13.4", @@ -131,12 +132,11 @@ "qs": "6.11.1", "resolve-cwd": "3.0.0", "semver": "7.3.8", - "statuses": "2.0.1", - "uuid": "^8.3.2" + "statuses": "2.0.1" }, "devDependencies": { "supertest": "6.3.3", - "typescript": "4.6.2" + "typescript": "5.0.4" }, "engines": { "node": ">=14.19.1 <=18.x.x", diff --git a/packages/core/upload/admin/src/components/AssetDialog/BrowseStep/tests/__snapshots__/index.test.js.snap b/packages/core/upload/admin/src/components/AssetDialog/BrowseStep/tests/__snapshots__/index.test.js.snap index e6daea64aa..3c783f3b2a 100644 --- a/packages/core/upload/admin/src/components/AssetDialog/BrowseStep/tests/__snapshots__/index.test.js.snap +++ b/packages/core/upload/admin/src/components/AssetDialog/BrowseStep/tests/__snapshots__/index.test.js.snap @@ -2,10 +2,19 @@ exports[`BrowseStep renders and match snapshot 1`] = ` .c0 { + margin-left: -250px; + position: fixed; + left: 50%; + top: 2.875rem; + z-index: 10; + width: 31.25rem; +} + +.c2 { padding-bottom: 16px; } -.c4 { +.c6 { background: #4945ff; padding: 8px; padding-right: 16px; @@ -16,12 +25,12 @@ exports[`BrowseStep renders and match snapshot 1`] = ` cursor: pointer; } -.c14 { +.c16 { padding-top: 4px; padding-bottom: 4px; } -.c16 { +.c18 { background: #ffffff; padding: 8px; border-radius: 4px; @@ -32,19 +41,19 @@ exports[`BrowseStep renders and match snapshot 1`] = ` cursor: pointer; } -.c20 { +.c22 { padding-top: 12px; } -.c29 { +.c31 { padding-bottom: 8px; } -.c33 { +.c35 { position: relative; } -.c36 { +.c38 { background: #ffffff; padding: 12px; border-radius: 4px; @@ -55,7 +64,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` cursor: pointer; } -.c37 { +.c39 { background: #eaf5ff; color: #66b7f1; padding-top: 8px; @@ -65,43 +74,58 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border-radius: 4px; } -.c39 { +.c41 { position: relative; overflow: hidden; max-width: 100%; } -.c42 { +.c44 { padding: 4px; max-width: 100%; } -.c44 { +.c46 { max-width: 100%; } -.c51 { +.c53 { right: 16px; } -.c54 { +.c56 { padding-top: 16px; } -.c60 { +.c62 { padding-right: 16px; padding-left: 16px; } -.c62 { +.c64 { padding-left: 12px; } -.c65 { +.c67 { padding-left: 8px; } .c1 { + -webkit-align-items: stretch; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + gap: 8px; +} + +.c3 { -webkit-align-items: flex-start; -webkit-box-align: flex-start; -ms-flex-align: flex-start; @@ -119,7 +143,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` justify-content: space-between; } -.c2 { +.c4 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -136,7 +160,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-wrap: wrap; } -.c5 { +.c7 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -151,7 +175,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` gap: 8px; } -.c10 { +.c12 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -165,7 +189,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: row; } -.c17 { +.c19 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -183,7 +207,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` justify-content: center; } -.c40 { +.c42 { -webkit-align-items: flex-start; -webkit-box-align: flex-start; -ms-flex-align: flex-start; @@ -197,7 +221,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: column; } -.c45 { +.c47 { -webkit-align-items: start; -webkit-box-align: start; -ms-flex-align: start; @@ -211,7 +235,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: column; } -.c55 { +.c57 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -229,7 +253,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` justify-content: space-between; } -.c56 { +.c58 { -webkit-align-items: stretch; -webkit-box-align: stretch; -ms-flex-align: stretch; @@ -243,14 +267,14 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: column; } -.c9 { +.c11 { font-size: 0.75rem; line-height: 1.33; font-weight: 600; color: #ffffff; } -.c30 { +.c32 { font-weight: 500; font-size: 1rem; line-height: 1.25; @@ -258,7 +282,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` color: #32324d; } -.c46 { +.c48 { font-size: 0.875rem; line-height: 1.43; display: block; @@ -269,7 +293,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` color: #32324d; } -.c48 { +.c50 { font-size: 0.75rem; line-height: 1.33; display: block; @@ -279,7 +303,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` color: #666687; } -.c61 { +.c63 { font-size: 0.875rem; line-height: 1.43; display: block; @@ -289,39 +313,39 @@ exports[`BrowseStep renders and match snapshot 1`] = ` color: #32324d; } -.c66 { +.c68 { font-size: 0.875rem; line-height: 1.43; color: #666687; } -.c72 { +.c74 { font-size: 0.75rem; line-height: 1.33; font-weight: 600; color: #32324d; } -.c6 { +.c8 { position: relative; outline: none; } -.c6 svg { +.c8 svg { height: 12px; width: 12px; } -.c6 svg > g, -.c6 svg path { +.c8 svg > g, +.c8 svg path { fill: #ffffff; } -.c6[aria-disabled='true'] { +.c8[aria-disabled='true'] { pointer-events: none; } -.c6:after { +.c8:after { -webkit-transition-property: all; transition-property: all; -webkit-transition-duration: 0.2s; @@ -336,11 +360,11 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border: 2px solid transparent; } -.c6:focus-visible { +.c8:focus-visible { outline: none; } -.c6:focus-visible:after { +.c8:focus-visible:after { border-radius: 8px; content: ''; position: absolute; @@ -351,7 +375,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border: 2px solid #4945ff; } -.c19 { +.c21 { border: 0; -webkit-clip: rect(0 0 0 0); clip: rect(0 0 0 0); @@ -363,70 +387,70 @@ exports[`BrowseStep renders and match snapshot 1`] = ` width: 1px; } -.c7 { +.c9 { height: 2rem; border: 1px solid #dcdce4; background: #ffffff; } -.c7[aria-disabled='true'] { +.c9[aria-disabled='true'] { border: 1px solid #dcdce4; background: #eaeaef; } -.c7[aria-disabled='true'] .c8 { +.c9[aria-disabled='true'] .c10 { color: #666687; } -.c7[aria-disabled='true'] svg > g,.c7[aria-disabled='true'] svg path { +.c9[aria-disabled='true'] svg > g,.c9[aria-disabled='true'] svg path { fill: #666687; } -.c7[aria-disabled='true']:active { +.c9[aria-disabled='true']:active { border: 1px solid #dcdce4; background: #eaeaef; } -.c7[aria-disabled='true']:active .c8 { +.c9[aria-disabled='true']:active .c10 { color: #666687; } -.c7[aria-disabled='true']:active svg > g,.c7[aria-disabled='true']:active svg path { +.c9[aria-disabled='true']:active svg > g,.c9[aria-disabled='true']:active svg path { fill: #666687; } -.c7:hover { +.c9:hover { background-color: #f6f6f9; } -.c7:active { +.c9:active { background-color: #eaeaef; } -.c7 .c8 { +.c9 .c10 { color: #32324d; } -.c7 svg > g, -.c7 svg path { +.c9 svg > g, +.c9 svg path { fill: #32324d; } -.c52 > * { +.c54 > * { margin-left: 0; margin-right: 0; } -.c52 > * + * { +.c54 > * + * { margin-left: 8px; } -.c53 { +.c55 { position: absolute; top: 12px; } -.c57 { +.c59 { position: relative; border: 1px solid #dcdce4; padding-right: 12px; @@ -442,28 +466,28 @@ exports[`BrowseStep renders and match snapshot 1`] = ` transition-duration: 0.2s; } -.c57:focus-within { +.c59:focus-within { border: 1px solid #4945ff; box-shadow: #4945ff 0px 0px 0px 2px; } -.c63 { +.c65 { background: transparent; border: none; position: relative; z-index: 1; } -.c63 svg { +.c65 svg { height: 0.6875rem; width: 0.6875rem; } -.c63 svg path { +.c65 svg path { fill: #666687; } -.c64 { +.c66 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -472,11 +496,11 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border: none; } -.c64 svg { +.c66 svg { width: 0.375rem; } -.c11 { +.c13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -487,12 +511,12 @@ exports[`BrowseStep renders and match snapshot 1`] = ` align-items: center; } -.c11 svg { +.c13 svg { height: 4px; width: 6px; } -.c58 { +.c60 { position: absolute; left: 0; right: 0; @@ -503,73 +527,73 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border: none; } -.c58:focus { +.c60:focus { outline: none; } -.c58[data-disabled] { +.c60[data-disabled] { cursor: not-allowed; } -.c59 { +.c61 { width: 100%; } -.c31 { +.c33 { display: grid; grid-template-columns: repeat(12,1fr); gap: 16px; } -.c32 { +.c34 { grid-column: span 3; max-width: 100%; } -.c18 svg > g, -.c18 svg path { +.c20 svg > g, +.c20 svg path { fill: #8e8ea9; } -.c18:hover svg > g, -.c18:hover svg path { +.c20:hover svg > g, +.c20:hover svg path { fill: #666687; } -.c18:active svg > g, -.c18:active svg path { +.c20:active svg > g, +.c20:active svg path { fill: #a5a5ba; } -.c18[aria-disabled='true'] svg path { +.c20[aria-disabled='true'] svg path { fill: #666687; } -.c24 { +.c26 { padding-top: 4px; padding-right: 8px; padding-bottom: 4px; padding-left: 8px; } -.c26 { +.c28 { padding-right: 4px; padding-left: 4px; } -.c25 { - font-size: 0.75rem; - line-height: 1.33; - color: #32324d; -} - .c27 { + font-size: 0.75rem; + line-height: 1.33; + color: #32324d; +} + +.c29 { font-size: 0.75rem; line-height: 1.33; color: #8e8ea9; } -.c21 { +.c23 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -583,7 +607,7 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: row; } -.c23 { +.c25 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -597,11 +621,11 @@ exports[`BrowseStep renders and match snapshot 1`] = ` flex-direction: row; } -.c22:first-child { +.c24:first-child { margin-left: calc(-1*8px); } -.c28 { +.c30 { border-radius: 4px; color: #666687; font-size: 0.75rem; @@ -611,13 +635,13 @@ exports[`BrowseStep renders and match snapshot 1`] = ` text-decoration: none; } -.c28:hover, -.c28:focus { +.c30:hover, +.c30:focus { background-color: #dcdce4; color: #4a4a6a; } -.c35 { +.c37 { height: 100%; left: 0; position: absolute; @@ -626,92 +650,48 @@ exports[`BrowseStep renders and match snapshot 1`] = ` width: 100%; } -.c35:hover, -.c35:focus { +.c37:hover, +.c37:focus { -webkit-text-decoration: none; text-decoration: none; } -.c38 path { +.c40 path { fill: currentColor; } -.c50 { +.c52 { display: none; } -.c34:hover .c49, -.c34:focus-within .c49 { +.c36:hover .c51, +.c36:focus-within .c51 { display: block; } -.c41 { +.c43 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } -.c43:focus { +.c45:focus { outline: 2px solid #4945ff; outline-offset: -2px; } -.c67 > * + * { +.c69 > * + * { margin-left: 4px; } -.c73 { +.c75 { line-height: revert; } -.c68 { - padding: 12px; - border-radius: 4px; - -webkit-text-decoration: none; - text-decoration: none; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - position: relative; - outline: none; -} - -.c68:after { - -webkit-transition-property: all; - transition-property: all; - -webkit-transition-duration: 0.2s; - transition-duration: 0.2s; - border-radius: 8px; - content: ''; - position: absolute; - top: -4px; - bottom: -4px; - left: -4px; - right: -4px; - border: 2px solid transparent; -} - -.c68:focus-visible { - outline: none; -} - -.c68:focus-visible:after { - border-radius: 8px; - content: ''; - position: absolute; - top: -5px; - bottom: -5px; - left: -5px; - right: -5px; - border: 2px solid #4945ff; -} - .c70 { padding: 12px; border-radius: 4px; - box-shadow: 0px 1px 4px rgba(33,33,52,0.1); -webkit-text-decoration: none; text-decoration: none; display: -webkit-box; @@ -752,79 +732,126 @@ exports[`BrowseStep renders and match snapshot 1`] = ` border: 2px solid #4945ff; } -.c71 { +.c72 { + padding: 12px; + border-radius: 4px; + box-shadow: 0px 1px 4px rgba(33,33,52,0.1); + -webkit-text-decoration: none; + text-decoration: none; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + position: relative; + outline: none; +} + +.c72:after { + -webkit-transition-property: all; + transition-property: all; + -webkit-transition-duration: 0.2s; + transition-duration: 0.2s; + border-radius: 8px; + content: ''; + position: absolute; + top: -4px; + bottom: -4px; + left: -4px; + right: -4px; + border: 2px solid transparent; +} + +.c72:focus-visible { + outline: none; +} + +.c72:focus-visible:after { + border-radius: 8px; + content: ''; + position: absolute; + top: -5px; + bottom: -5px; + left: -5px; + right: -5px; + border: 2px solid #4945ff; +} + +.c73 { color: #271fe0; background: #ffffff; } -.c71:hover { +.c73:hover { box-shadow: 0px 1px 4px rgba(33,33,52,0.1); } -.c69 { +.c71 { font-size: 0.7rem; pointer-events: none; } -.c69 svg path { +.c71 svg path { fill: #c0c0cf; } -.c69:focus svg path, -.c69:hover svg path { +.c71:focus svg path, +.c71:hover svg path { fill: #c0c0cf; } -.c3 > * + * { +.c5 > * + * { margin-left: 8px; } -.c12 { +.c14 { margin-left: auto; } -.c12 > * + * { +.c14 > * + * { margin-left: 8px; } -.c13 { +.c15 { -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0; } -.c47 { +.c49 { max-width: 100%; } -.c15 svg path { +.c17 svg path { fill: #8e8ea9; } @media (max-width:68.75rem) { - .c32 { + .c34 { grid-column: span; } } @media (max-width:34.375rem) { - .c32 { + .c34 { grid-column: span; } }
+
@@ -833,21 +860,21 @@ exports[`BrowseStep renders and match snapshot 1`] = ` aria-disabled="false" aria-expanded="false" aria-haspopup="true" - class="c4 c5 c6 c7" + class="c6 c7 c8 c9" label="Sort by" type="button" > Sort by

g, -.c12 svg path { +.c14 svg > g, +.c14 svg path { fill: #ffffff; } -.c12[aria-disabled='true'] { +.c14[aria-disabled='true'] { pointer-events: none; } -.c12:after { +.c14:after { -webkit-transition-property: all; transition-property: all; -webkit-transition-duration: 0.2s; @@ -187,11 +211,11 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` border: 2px solid transparent; } -.c12:focus-visible { +.c14:focus-visible { outline: none; } -.c12:focus-visible:after { +.c14:focus-visible:after { border-radius: 8px; content: ''; position: absolute; @@ -202,7 +226,7 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` border: 2px solid #4945ff; } -.c0 { +.c2 { border: 0; -webkit-clip: rect(0 0 0 0); clip: rect(0 0 0 0); @@ -214,65 +238,18 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` width: 1px; } -.c29 { +.c31 { height: 2rem; border: 1px solid #dcdce4; background: #ffffff; } -.c29[aria-disabled='true'] { - border: 1px solid #dcdce4; - background: #eaeaef; -} - -.c29[aria-disabled='true'] .c9 { - color: #666687; -} - -.c29[aria-disabled='true'] svg > g,.c29[aria-disabled='true'] svg path { - fill: #666687; -} - -.c29[aria-disabled='true']:active { - border: 1px solid #dcdce4; - background: #eaeaef; -} - -.c29[aria-disabled='true']:active .c9 { - color: #666687; -} - -.c29[aria-disabled='true']:active svg > g,.c29[aria-disabled='true']:active svg path { - fill: #666687; -} - -.c29:hover { - background-color: #f6f6f9; -} - -.c29:active { - background-color: #eaeaef; -} - -.c29 .c9 { - color: #32324d; -} - -.c29 svg > g, -.c29 svg path { - fill: #32324d; -} - -.c31 { - height: 2rem; -} - .c31[aria-disabled='true'] { border: 1px solid #dcdce4; background: #eaeaef; } -.c31[aria-disabled='true'] .c9 { +.c31[aria-disabled='true'] .c11 { color: #666687; } @@ -285,7 +262,7 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` background: #eaeaef; } -.c31[aria-disabled='true']:active .c9 { +.c31[aria-disabled='true']:active .c11 { color: #666687; } @@ -294,21 +271,68 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` } .c31:hover { - border: 1px solid #7b79ff; - background: #7b79ff; + background-color: #f6f6f9; } .c31:active { - border: 1px solid #4945ff; - background: #4945ff; + background-color: #eaeaef; +} + +.c31 .c11 { + color: #32324d; } .c31 svg > g, .c31 svg path { + fill: #32324d; +} + +.c33 { + height: 2rem; +} + +.c33[aria-disabled='true'] { + border: 1px solid #dcdce4; + background: #eaeaef; +} + +.c33[aria-disabled='true'] .c11 { + color: #666687; +} + +.c33[aria-disabled='true'] svg > g,.c33[aria-disabled='true'] svg path { + fill: #666687; +} + +.c33[aria-disabled='true']:active { + border: 1px solid #dcdce4; + background: #eaeaef; +} + +.c33[aria-disabled='true']:active .c11 { + color: #666687; +} + +.c33[aria-disabled='true']:active svg > g,.c33[aria-disabled='true']:active svg path { + fill: #666687; +} + +.c33:hover { + border: 1px solid #7b79ff; + background: #7b79ff; +} + +.c33:active { + border: 1px solid #4945ff; + background: #4945ff; +} + +.c33 svg > g, +.c33 svg path { fill: #ffffff; } -.c20 { +.c22 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -319,81 +343,81 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` align-items: center; } -.c16 { +.c18 { display: grid; grid-template-columns: repeat(12,1fr); gap: 16px; } -.c17 { +.c19 { grid-column: span 12; max-width: 100%; } -.c13 svg > g, -.c13 svg path { +.c15 svg > g, +.c15 svg path { fill: #8e8ea9; } -.c13:hover svg > g, -.c13:hover svg path { +.c15:hover svg > g, +.c15:hover svg path { fill: #666687; } -.c13:active svg > g, -.c13:active svg path { +.c15:active svg > g, +.c15:active svg path { fill: #a5a5ba; } -.c13[aria-disabled='true'] svg path { +.c15[aria-disabled='true'] svg path { fill: #666687; } -.c3 { +.c5 { inset: 0; background: #32324d1F; } -.c5 { +.c7 { width: 51.875rem; } -.c7 { +.c9 { border-radius: 4px 4px 0 0; border-bottom: 1px solid #eaeaef; } -.c24 { +.c26 { border-radius: 0 0 4px 4px; border-top: 1px solid #eaeaef; } -.c26 > * + * { +.c28 > * + * { margin-left: 8px; } -.c15 { +.c17 { overflow: auto; max-height: 60vh; } -.c22 { +.c24 { background: transparent; border: none; position: relative; z-index: 1; } -.c22 svg { +.c24 svg { height: 0.6875rem; width: 0.6875rem; } -.c22 svg path { +.c24 svg path { fill: #666687; } -.c23 { +.c25 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -402,18 +426,18 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` border: none; } -.c23 svg { +.c25 svg { width: 0.5625rem; } @media (max-width:68.75rem) { - .c17 { + .c19 { grid-column: span; } } @media (max-width:34.375rem) { - .c17 { + .c19 { grid-column: span 12; } } @@ -423,7 +447,10 @@ exports[`BulkMoveDialog renders and matches the snapshot 1`] = ` >

+