Merge branch 'main' into chore/docs-api-ref

This commit is contained in:
Nathan Pichon 2023-05-02 15:25:49 +02:00 committed by GitHub
commit fd2f06ee8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
720 changed files with 26073 additions and 17866 deletions

View File

@ -0,0 +1,6 @@
'use strict';
module.exports = {
preset: '../../../jest-preset.unit.js',
displayName: 'Github action check-pr-status',
};

View File

@ -1,6 +1,6 @@
{
"name": "check-pr-status",
"version": "4.9.2",
"version": "4.10.1",
"main": "dist/index.js",
"license": "MIT",
"private": true,

View File

@ -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'

14
.github/filters.yaml vendored Normal file
View File

@ -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/**'

View File

@ -1,3 +0,0 @@
module.exports = {
displayName: '.github',
};

View File

@ -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]

View File

@ -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:

View File

@ -2,4 +2,3 @@
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged
yarn nx affected:lint --uncommitted --nx-ignore-cycles

View File

@ -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",

View File

@ -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 () => {

View File

@ -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);
}
});
});
});

View File

@ -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';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -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)

View File

@ -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';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -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
}
```

View File

@ -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
}
}
```

View File

@ -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

View File

@ -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';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -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.

View File

@ -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',

View File

@ -17,7 +17,7 @@ module.exports = () => ({
documentation: {
config: {
info: {
version: '2.0.0',
version: '1.0.0',
},
},
},

View File

@ -1,7 +1,7 @@
{
"name": "getstarted",
"private": true,
"version": "4.9.2",
"version": "4.10.1",
"description": "A Strapi application.",
"scripts": {
"develop": "strapi develop",
@ -12,26 +12,26 @@
"strapi": "strapi"
},
"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/icons": "1.7.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": {

View File

@ -9,8 +9,8 @@
"name": "Address"
},
"options": {
"draftAndPublish": false,
"comment": ""
"reviewWorkflows": true,
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {

View File

@ -9,8 +9,8 @@
"name": "Category"
},
"options": {
"draftAndPublish": true,
"comment": ""
"reviewWorkflows": true,
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {

View File

@ -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"

View File

@ -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",

View File

@ -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$': [

12
jest.config.front.js Normal file
View File

@ -0,0 +1,12 @@
'use strict';
/** @type {import('jest').Config} */
const config = {
projects: [
'<rootDir>/packages/plugins/*/jest.config.front.js',
'<rootDir>/packages/core/*/jest.config.front.js',
'<rootDir>/scripts/*/jest.config.front.js',
],
};
module.exports = config;

15
jest.config.js Normal file
View File

@ -0,0 +1,15 @@
'use strict';
/** @type {import('jest').Config} */
const config = {
projects: [
'<rootDir>/packages/plugins/*/jest.config.js',
'<rootDir>/packages/utils/*/jest.config.js',
'<rootDir>/packages/generators/*/jest.config.js',
'<rootDir>/packages/core/*/jest.config.js',
'<rootDir>/packages/providers/*/jest.config.js',
'<rootDir>/.github/actions/*/jest.config.js',
],
};
module.exports = config;

View File

@ -1,5 +1,5 @@
{
"version": "4.9.2",
"version": "4.10.1",
"packages": ["packages/*", "examples/*"],
"npmClient": "yarn",
"useWorkspaces": true,

50
lint-staged.config.js Normal file
View File

@ -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'],
};

View File

@ -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": {

View File

@ -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",

View File

@ -1,2 +1,3 @@
node_modules/
dist/
.eslintrc.js

View File

@ -1,7 +1,4 @@
module.exports = {
root: true,
extends: ['custom/back'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
extends: ['custom/typescript'],
};

99
packages/admin-test-utils/.gitignore vendored Executable file
View File

@ -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/

View File

@ -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.

15
packages/admin-test-utils/custom.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
export {};
declare global {
interface Window {
strapi: {
backendURL: string;
isEE: boolean;
features: {
SSO: 'sso';
isEnabled: (featureName?: string) => boolean;
};
projectType: string;
};
}
}

View File

@ -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,
};

View File

@ -1,7 +0,0 @@
'use strict';
const fixtures = require('./fixtures');
module.exports = {
fixtures,
};

View File

@ -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;

View File

@ -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(),
});

View File

@ -1,11 +0,0 @@
'use strict';
class ResizeObserverMock {
constructor() {
this.disconnect = () => null;
this.observe = () => null;
this.unobserve = () => null;
}
}
global.ResizeObserver = ResizeObserverMock;

View File

@ -1 +0,0 @@
// module.exports = 'CSS_MODULE';

View File

@ -1,4 +0,0 @@
'use strict';
// Required as long as we are running tests on node@14 and node@16
require('whatwg-fetch');

View File

@ -1,3 +0,0 @@
'use strict';
module.exports = 'IMAGE_MOCK';

View File

@ -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;
};

View File

@ -1,3 +0,0 @@
'use strict';
global.window.matchMedia = jest.fn(() => false);

View File

@ -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;
});

View File

@ -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}`;

View File

@ -1,3 +0,0 @@
'use strict';
require('jest-styled-components');

View File

@ -1,5 +0,0 @@
'use strict';
const noop = () => {};
// eslint-disable-next-line no-undef
Object.defineProperty(window, 'scrollTo', { value: noop, writable: true });

View File

@ -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": {

View File

@ -0,0 +1,2 @@
import '@testing-library/jest-dom';
import 'jest-styled-components';

View File

@ -0,0 +1,202 @@
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<string, string>;
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(),
});
/* -------------------------------------------------------------------------------------------------
* PointerEvents
* -----------------------------------------------------------------------------------------------*/
/**
* JSDOM doesn't implement PointerEvent so we need to mock our own implementation
* Default to mouse left click interaction
* https://github.com/radix-ui/primitives/issues/1822
* https://github.com/jsdom/jsdom/pull/2666
*/
class MockPointerEvent extends Event {
button: number;
ctrlKey: boolean;
pointerType: string;
constructor(
type: string,
props: EventInit & { button?: number; ctrlKey?: boolean; pointerType?: string }
) {
super(type, props);
this.button = props.button || 0;
this.ctrlKey = props.ctrlKey || false;
this.pointerType = props.pointerType || 'mouse';
}
}
Object.defineProperty(window, 'PointerEvent', {
writable: true,
value: MockPointerEvent,
});
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLElement.prototype.releasePointerCapture = jest.fn();
window.HTMLElement.prototype.hasPointerCapture = jest.fn();

View File

@ -0,0 +1 @@
export default 'IMAGE_MOCK';

View File

@ -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 };

View File

@ -0,0 +1,3 @@
import { address, Address } from './address';
export { Address, address };

View File

@ -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 };

View File

@ -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 };

View File

@ -0,0 +1,3 @@
import { address, Address } from './address';
export { Address, address };

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
};

View File

@ -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(),
};

View File

@ -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;

View File

@ -0,0 +1 @@
export * as fixtures from './fixtures';

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
},
}

View File

@ -0,0 +1,8 @@
{
"extends": "tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "./custom.d.ts"],
"exclude": ["node_modules", "**/__tests__/**"]
}

View File

@ -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",

View File

@ -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",

View File

@ -1 +0,0 @@
declare module '@strapi/generate-new';

View File

@ -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 });

View File

@ -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) {

View File

@ -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 <LoadingIndicatorPage />;
}
// TODO add error state
// TODO: add error state
if (status === 'error') {
return <div>error...</div>;
}
return (
<AppInfosContext.Provider value={appInfosValue}>
<AppInfoProvider
{...appInfos}
userId={userId}
latestStrapiReleaseTag={tagName}
setUserDisplayName={setUserDisplayName}
shouldUpdateStrapi={shouldUpdateStrapi}
userDisplayName={userDisplayName}
>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<PluginsInitializer />
</RBACProvider>
</AppInfosContext.Provider>
</AppInfoProvider>
);
};

View File

@ -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(
<Overlay>
<Content direction="column" alignItems="stretch" gap={6}>
<Flex direction="column" alignItems="stretch" gap={2}>
<Flex justifyContent="center">
<Typography as="h1" variant="alpha">
{formatMessage(title)}
</Typography>
</Flex>
<Flex justifyContent="center">
<Typography as="h2" textColor="neutral600" fontSize={4} fontWeight="regular">
{formatMessage(description)}
</Typography>
</Flex>
</Flex>
<Flex justifyContent="center">
{displayedIcon === 'reload' && (
<IconBox padding={6} background="primary100" borderColor="primary200">
<LoaderReload width={pxToRem(36)} height={pxToRem(36)} />
</IconBox>
)}
{displayedIcon === 'time' && (
<IconBox padding={6} background="primary100" borderColor="primary200">
<Clock width={pxToRem(40)} height={pxToRem(40)} />
</IconBox>
)}
</Flex>
<Flex justifyContent="center">
<Box paddingTop={2}>
<Link href="https://docs.strapi.io" isExternal>
{formatMessage({
id: 'global.documentation',
defaultMessage: 'Read the documentation',
})}
</Link>
</Box>
</Flex>
</Content>
</Overlay>,
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;

View File

@ -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 };

View File

@ -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 (
<AutoReloadOverlayBockerContext.Provider value={autoReloadValue}>
<Blocker
displayedIcon={displayedIcon}
isOpen={isOpen}
description={description}
title={title}
/>
{children}
</AutoReloadOverlayBockerContext.Provider>
);
};
AutoReloadOverlayBlockerProvider.propTypes = {
children: PropTypes.element.isRequired,
};
export default AutoReloadOverlayBlockerProvider;

View File

@ -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 (
<Box
height="100%"
left={0}
position="fixed"
pointerEvents="none"
top={0}
zIndex={100}
width="100%"
>
<Box style={getStyle(initialOffset, currentOffset, mouseOffset)}>
{renderItem({ type: itemType, item })}
</Box>
</Box>
);
}
DragLayer.propTypes = {
renderItem: PropTypes.func.isRequired,
};

View File

@ -0,0 +1 @@
export * from './DragLayer';

View File

@ -96,6 +96,27 @@ describe('GuidedTour Stepper', () => {
min-height: 4.0625rem;
}
.c4 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c5 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c11 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
@ -142,27 +163,6 @@ describe('GuidedTour Stepper', () => {
flex-direction: row;
}
.c4 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c5 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c11 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
<div
class=""
>
@ -351,6 +351,27 @@ describe('GuidedTour Stepper', () => {
height: 1.875rem;
}
.c6 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c11 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c14 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
@ -397,27 +418,6 @@ describe('GuidedTour Stepper', () => {
flex-direction: row;
}
.c6 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c11 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c14 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
.c5 path {
fill: #ffffff;
}
@ -601,6 +601,13 @@ describe('GuidedTour Stepper', () => {
margin-top: 8px;
}
.c6 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
@ -647,13 +654,6 @@ describe('GuidedTour Stepper', () => {
flex-direction: row;
}
.c6 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c5 path {
fill: #ffffff;
}

View File

@ -120,6 +120,41 @@ describe('GuidedTour Homepage', () => {
cursor: pointer;
}
.c3 {
font-weight: 600;
font-size: 1.125rem;
line-height: 1.22;
color: #32324d;
}
.c8 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c9 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c22 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
.c29 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c1 {
-webkit-align-items: stretch;
-webkit-box-align: stretch;
@ -214,53 +249,18 @@ describe('GuidedTour Homepage', () => {
gap: 8px;
}
.c3 {
font-weight: 600;
font-size: 1.125rem;
line-height: 1.22;
color: #32324d;
}
.c8 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c9 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #32324d;
}
.c22 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #666687;
}
.c29 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c27 {
position: relative;
outline: none;
}
.c27 svg {
.c27 > svg {
height: 12px;
width: 12px;
}
.c27 svg > g,
.c27 svg path {
.c27 > svg > g,
.c27 > svg path {
fill: #ffffff;
}
@ -399,13 +399,13 @@ describe('GuidedTour Homepage', () => {
outline: none;
}
.c16 svg {
.c16 > svg {
height: 12px;
width: 12px;
}
.c16 svg > g,
.c16 svg path {
.c16 > svg > g,
.c16 > svg path {
fill: #ffffff;
}

View File

@ -117,6 +117,42 @@ describe('<GuidedTourModal />', () => {
cursor: pointer;
}
.c15 {
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
color: #4945ff;
}
.c19 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c20 {
font-weight: 600;
font-size: 2rem;
line-height: 1.25;
font-weight: 600;
color: #32324d;
}
.c24 {
font-size: 0.875rem;
line-height: 1.43;
color: #32324d;
}
.c28 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c2 {
-webkit-align-items: center;
-webkit-box-align: center;
@ -244,54 +280,18 @@ describe('<GuidedTourModal />', () => {
gap: 8px;
}
.c15 {
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
color: #4945ff;
}
.c19 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 500;
color: #ffffff;
}
.c20 {
font-weight: 600;
font-size: 2rem;
line-height: 1.25;
font-weight: 600;
color: #32324d;
}
.c24 {
font-size: 0.875rem;
line-height: 1.43;
color: #32324d;
}
.c28 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c8 {
position: relative;
outline: none;
}
.c8 svg {
.c8 > svg {
height: 12px;
width: 12px;
}
.c8 svg > g,
.c8 svg path {
.c8 > svg > g,
.c8 > svg path {
fill: #ffffff;
}
@ -487,7 +487,7 @@ describe('<GuidedTourModal />', () => {
</div>
</div>
<div
data-react-portal="true"
class=""
>
<div
class="c1 c2 c3"

View File

@ -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();

View File

@ -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 (
<Alert
action={
link ? (
<Link href={link.url} isExternal>
{formatMessage({
id: link.label?.id || link.label,
defaultMessage: link.label?.defaultMessage || link.label?.id || link.label,
})}
</Link>
) : undefined
}
onClose={handleClose}
closeLabel="Close"
title={alertTitle}
variant={variant}
>
{formattedMessage({
id: message?.id || message,
defaultMessage: message?.defaultMessage || message?.id || message,
values: message?.values,
})}
</Alert>
);
};
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;

View File

@ -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 (
<NotificationsProvider toggleNotification={displayNotification}>
<Flex
left="50%"
marginLeft="-250px"
position="fixed"
direction="column"
alignItems="stretch"
gap={2}
top={`${46 / 16}rem`}
width={`${500 / 16}rem`}
zIndex={10}
>
{notifications.map((notification) => {
return (
<Notification key={notification.id} dispatch={dispatch} notification={notification} />
);
})}
</Flex>
{children}
</NotificationsProvider>
);
};
Notifications.propTypes = {
children: PropTypes.element.isRequired,
};
export default Notifications;

View File

@ -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 };

View File

@ -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('<Notifications />', () => {
it('renders and matches the snapshot', () => {
const {
container: { firstChild },
} = render(
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" messages={messages} defaultLocale="en" textComponent="span">
<Notifications>
<div />
</Notifications>
</IntlProvider>
</Theme>
</ThemeToggleProvider>
);
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;
}
<div
class="c0 c1"
/>
`);
});
it('should display a notification correctly', async () => {
const Button = () => {
const toggleNotification = useNotification();
const handleClick = () => {
toggleNotification({ type: 'success', message: 'simple notif' });
};
return (
<button onClick={handleClick} type="button">
display notif
</button>
);
};
render(
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" defaultLocale="en" messages={messages} textComponent="span">
<Notifications>
<Button />
</Notifications>
</IntlProvider>
</Theme>
</ThemeToggleProvider>
);
// Click button
fireEvent.click(screen.getByText('display notif'));
const items = await screen.findAllByText(/simple notif/);
expect(items).toHaveLength(1);
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 2500);
});
});
const foundItems = screen.queryAllByText(/simple notif/);
expect(foundItems).toHaveLength(0);
});
it('should display a notification correctly and not toggle it', async () => {
const Button = () => {
const toggleNotification = useNotification();
const handleClick = () => {
toggleNotification({ type: 'success', message: 'simple notif', blockTransition: true });
};
return (
<button onClick={handleClick} type="button">
display notif
</button>
);
};
render(
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" defaultLocale="en" messages={messages} textComponent="span">
<Notifications>
<Button />
</Notifications>
</IntlProvider>
</Theme>
</ThemeToggleProvider>
);
// Click button
fireEvent.click(screen.getByText('display notif'));
const items = await screen.findAllByText(/simple notif/);
expect(items).toHaveLength(1);
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 2500);
});
});
const foundItems = screen.queryAllByText(/simple notif/);
expect(foundItems).toHaveLength(1);
fireEvent.click(screen.getByLabelText('Close'));
const displayedItems = screen.queryAllByText(/simple notif/);
expect(displayedItems).toHaveLength(0);
});
});

View File

@ -1,80 +0,0 @@
import reducer from '../reducer';
describe('ADMIN | COMPONENTS | NOTIFICATIONS | reducer', () => {
describe('DEFAULT_ACTION', () => {
it('should return the initialState', () => {
const state = {
test: true,
};
expect(reducer(state, {})).toEqual(state);
});
});
describe('SHOW_NOTIFICATION', () => {
it('should add a notification', () => {
const action = {
type: 'SHOW_NOTIFICATION',
config: {
type: 'success',
message: {
id: 'notification.message',
},
},
};
const initialState = {
notifications: [],
notifId: 0,
};
const expected = {
notifications: [
{
id: 0,
type: 'success',
message: { id: 'notification.message' },
link: null,
timeout: 2500,
blockTransition: false,
onClose: null,
title: null,
},
],
notifId: 1,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('HIDE_NOTIFICATION', () => {
it('should remove a notification if the notification exist', () => {
const action = {
type: 'HIDE_NOTIFICATION',
id: 1,
};
const initialState = {
notifications: [{ id: 1, message: { id: 'notification.message' }, type: 'success' }],
};
const expected = {
notifications: [],
};
expect(reducer(initialState, action)).toEqual(expected);
});
it('should not remove the notification if the notification does not exist', () => {
const action = {
type: 'HIDE_NOTIFICATION',
id: 3,
};
const initialState = {
notifications: [{ id: 1, message: { id: 'notification.message' }, type: 'success' }],
};
const expected = {
notifications: [{ id: 1, message: { id: 'notification.message' }, type: 'success' }],
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
});

View File

@ -1,67 +0,0 @@
/**
*
* OverlayBlockerProvider
*
*/
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { OverlayBlockerContext } from '@strapi/helper-plugin';
const overlayContainer = document.createElement('div');
overlayContainer.setAttribute('id', 'overlayBlocker');
const Overlay = styled.div`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1140;
`;
const Portal = ({ isOpen }) => {
useEffect(() => {
document.body.appendChild(overlayContainer);
return () => {
document.body.removeChild(overlayContainer);
};
}, []);
if (isOpen) {
return ReactDOM.createPortal(<Overlay />, overlayContainer);
}
return null;
};
Portal.propTypes = {
isOpen: PropTypes.bool.isRequired,
};
const OverlayBlockerProvider = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const lockApp = () => {
setIsOpen(true);
};
const unlockApp = () => {
setIsOpen(false);
};
return (
<OverlayBlockerContext.Provider value={{ lockApp, unlockApp }}>
{children}
<Portal isOpen={isOpen} />
</OverlayBlockerContext.Provider>
);
};
OverlayBlockerProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default OverlayBlockerProvider;

View File

@ -1,15 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { QueryClientProvider, QueryClient } from 'react-query';
import { LibraryProvider, CustomFieldsProvider, StrapiAppProvider } from '@strapi/helper-plugin';
import {
LibraryProvider,
CustomFieldsProvider,
StrapiAppProvider,
AutoReloadOverlayBlockerProvider,
OverlayBlockerProvider,
NotificationsProvider,
} from '@strapi/helper-plugin';
import { Provider } from 'react-redux';
import { AdminContext } from '../../contexts';
import ConfigurationsProvider from '../ConfigurationsProvider';
import LanguageProvider from '../LanguageProvider';
import GuidedTour from '../GuidedTour';
import AutoReloadOverlayBlockerProvider from '../AutoReloadOverlayBlockerProvider';
import Notifications from '../Notifications';
import OverlayBlocker from '../OverlayBlocker';
import ThemeToggleProvider from '../ThemeToggleProvider';
import Theme from '../Theme';
@ -68,11 +72,11 @@ const Providers = ({
<CustomFieldsProvider customFields={customFields}>
<LanguageProvider messages={messages} localeNames={localeNames}>
<AutoReloadOverlayBlockerProvider>
<OverlayBlocker>
<OverlayBlockerProvider>
<GuidedTour>
<Notifications>{children}</Notifications>
<NotificationsProvider>{children}</NotificationsProvider>
</GuidedTour>
</OverlayBlocker>
</OverlayBlockerProvider>
</AutoReloadOverlayBlockerProvider>
</LanguageProvider>
</CustomFieldsProvider>

View File

@ -1,4 +1,4 @@
import { permissions } from '@strapi/admin-test-utils/lib/fixtures';
import { fixtures } from '@strapi/admin-test-utils';
import { setPermissions, resetStore } from '../actions';
import rbacProviderReducer, { initialState } from '../reducer';
@ -89,7 +89,8 @@ describe('rbacProviderReducer', () => {
};
expect(
rbacProviderReducer(state, setPermissions(permissions)).collectionTypesRelatedPermissions
rbacProviderReducer(state, setPermissions(fixtures.permissions.allPermissions))
.collectionTypesRelatedPermissions
).toEqual(expected);
});
});

View File

@ -1,83 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { pxToRem } from '@strapi/helper-plugin';
import { Box, Flex, Typography, IconButton } from '@strapi/design-system';
import { Trash, Drag, CarretDown } from '@strapi/icons';
const DragPreviewBox = styled(Box)`
border: 1px solid ${({ theme }) => theme.colors.neutral200};
`;
const DropdownIconWrapper = styled(Box)`
height: ${32 / 16}rem;
width: ${32 / 16}rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
svg {
height: ${6 / 16}rem;
width: ${11 / 16}rem;
> path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
`;
const ToggleButton = styled.button`
border: none;
background: transparent;
display: block;
width: 100%;
text-align: unset;
padding: 0;
`;
const DragPreview = ({ displayedValue }) => {
return (
<DragPreviewBox
paddingLeft={3}
paddingRight={3}
paddingTop={3}
paddingBottom={3}
hasRadius
background="neutral0"
width={pxToRem(300)}
>
<Flex justifyContent="space-between">
<ToggleButton type="button">
<Flex>
<DropdownIconWrapper background="neutral200">
<CarretDown />
</DropdownIconWrapper>
<Flex gap={2} paddingLeft={6} maxWidth={pxToRem(150)}>
<Typography textColor="neutral700" ellipsis>
{displayedValue}
</Typography>
</Flex>
</Flex>
</ToggleButton>
<Box paddingLeft={3}>
<Flex>
<IconButton noBorder>
<Trash />
</IconButton>
<Box paddingLeft={2}>
<IconButton noBorder>
<Drag />
</IconButton>
</Box>
</Flex>
</Box>
</Flex>
</DragPreviewBox>
);
};
DragPreview.propTypes = {
displayedValue: PropTypes.string.isRequired,
};
export default DragPreview;

View File

@ -1,85 +0,0 @@
import React from 'react';
import { useDragLayer } from 'react-dnd';
import LayoutDndProvider from '../LayoutDndProvider';
import ItemTypes from '../../utils/ItemTypes';
import CardPreview from '../../pages/ListSettingsView/components/CardPreview';
import ComponentPreview from './ComponentDragPreview';
import { RelationDragPreview } from './RelationDragPreview';
const layerStyles = {
position: 'fixed',
pointerEvents: 'none',
zIndex: 100,
left: 0,
top: 0,
width: '100%',
height: '100%',
};
function getItemStyles(initialOffset, currentOffset, mouseOffset) {
if (!initialOffset || !currentOffset) {
return { display: 'none' };
}
const { x, y } = mouseOffset;
// TODO adjust
const transform = `translate(${x}px, ${y}px)`;
return {
transform,
WebkitTransform: transform,
};
}
const CustomDragLayer = () => {
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;
}
/**
* Because a user may have multiple relations / dynamic zones / repeable fields in the same content type,
* we append the fieldName for the item type to make them unique, however, we then want to extract that
* first type to apply the correct preview.
*/
const [actualType] = itemType.split('_');
return (
<LayoutDndProvider>
<div style={layerStyles}>
<div style={getItemStyles(initialOffset, currentOffset, mouseOffset)} className="col-md-2">
{[ItemTypes.EDIT_FIELD, ItemTypes.FIELD].includes(itemType) && (
<CardPreview labelField={item.labelField} />
)}
{actualType === ItemTypes.COMPONENT && (
<ComponentPreview displayedValue={item.displayedValue} />
)}
{actualType === ItemTypes.DYNAMIC_ZONE && (
<ComponentPreview displayedValue={item.displayedValue} />
)}
{actualType === ItemTypes.RELATION && (
<RelationDragPreview
displayedValue={item.displayedValue}
status={item.status}
width={item.width}
/>
)}
</div>
</div>
</LayoutDndProvider>
);
};
export default CustomDragLayer;

View File

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Status, Typography } from '@strapi/design-system';
import { getTrad } from '../../../../utils';
export function PublicationState({ isPublished }) {
const { formatMessage } = useIntl();
const variant = isPublished ? 'success' : 'secondary';
return (
<Status showBullet={false} variant={variant} size="S" width="min-content">
<Typography fontWeight="bold" textColor={`${variant}700`}>
{formatMessage({
id: getTrad(`containers.List.${isPublished ? 'published' : 'draft'}`),
defaultMessage: isPublished ? 'Published' : 'Draft',
})}
</Typography>
</Status>
);
}
PublicationState.propTypes = {
isPublished: PropTypes.bool.isRequired,
};

View File

@ -0,0 +1 @@
export * from './PublicationState';

View File

@ -0,0 +1,42 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import { PublicationState } from '..';
const ComponentFixture = (props) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}}>
<PublicationState {...props} />
</IntlProvider>
</ThemeProvider>
);
const setup = (props) => render(<ComponentFixture {...props} />);
describe('DynamicTable | PublicationState', () => {
test('render draft state', () => {
const { container, getByText } = setup({ isPublished: false });
// retreive styles of rendered component, rather than the container
const statusNodeStyle = window.getComputedStyle(container.firstChild);
const textNode = getByText('Draft');
expect(textNode).toBeInTheDocument();
expect(statusNodeStyle).toHaveProperty('background-color', 'rgb(234, 245, 255)');
expect(window.getComputedStyle(textNode)).toHaveProperty('color', 'rgb(12, 117, 175)');
});
test('render published state', () => {
const { container, getByText } = setup({ isPublished: true });
// retreive styles of rendered component, rather than the container
const statusNodeStyle = window.getComputedStyle(container.firstChild);
const textNode = getByText('Published');
expect(textNode).toBeInTheDocument();
expect(statusNodeStyle).toHaveProperty('background-color', 'rgb(234, 251, 231)');
expect(window.getComputedStyle(textNode)).toHaveProperty('color', 'rgb(50, 128, 72)');
});
});

View File

@ -19,6 +19,21 @@ exports[`DynamicTable / Cellcontent / RelationMultiple renders and matches the s
min-width: 20px;
}
.c6 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c11 {
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
color: #666687;
}
.c1 {
-webkit-align-items: center;
-webkit-box-align: center;
@ -84,21 +99,6 @@ exports[`DynamicTable / Cellcontent / RelationMultiple renders and matches the s
flex-direction: row;
}
.c6 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #ffffff;
}
.c11 {
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
color: #666687;
}
.c10 {
border-radius: 4px;
height: 1.5rem;
@ -109,13 +109,13 @@ exports[`DynamicTable / Cellcontent / RelationMultiple renders and matches the s
outline: none;
}
.c2 svg {
.c2 > svg {
height: 12px;
width: 12px;
}
.c2 svg > g,
.c2 svg path {
.c2 > svg > g,
.c2 > svg path {
fill: #ffffff;
}

Some files were not shown because too many files have changed in this diff Show More