Merge branch 'releases/v4' into pluginAPI/loadPlugin

This commit is contained in:
Pierre Noël 2021-06-29 12:07:29 +02:00
commit 5dd7f6c1be
734 changed files with 10659 additions and 7411 deletions

View File

@ -6,7 +6,10 @@ testApp/**
examples/**
cypress/**
packages/generators/plugin/files/admin/src/**
packages/core/helper-plugin/**
packages/core/helper-plugin/build/**
packages/core/helper-plugin/lib/src/components/**
packages/core/helper-plugin/lib/src/testUtils/**
packages/core/helper-plugin/lib/src/utils/**
packages/plugins/users-permissions/admin/OLD/**
.eslintrc.js
.eslintrc.front.js

View File

@ -32,5 +32,6 @@ module.exports = {
'node/no-path-concat': 'error',
'node/no-callback-literal': 'error',
'node/handle-callback-err': 'error',
'one-var': ['error', 'never'],
},
};

View File

@ -91,6 +91,7 @@ module.exports = {
'no-underscore-dangle': 0,
'no-use-before-define': ['error', { functions: false, classes: false, variables: false }],
'object-curly-newline': [2, { multiline: true, consistent: true }],
'one-var': ['error', 'never'],
'operator-linebreak': 0,
'padding-line-between-statements': [
'error',

View File

@ -7,3 +7,5 @@ updates:
versioning-strategy: increase
reviewers:
- strapi/maintainers
# disable dependency pull requests while we work on v4
open-pull-requests-limit: 0

View File

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
@ -32,7 +32,7 @@ jobs:
CODECOV_TOKEN: ${{ secrets.codecov }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
@ -52,7 +52,7 @@ jobs:
CODECOV_TOKEN: ${{ secrets.codecov }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
@ -72,7 +72,7 @@ jobs:
name: '[CE] E2E (postgres, node: ${{ matrix.node }})'
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
postgres:
@ -108,7 +108,7 @@ jobs:
name: '[CE] E2E (mysql, node: ${{ matrix.node }})'
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
mysql:
@ -143,7 +143,7 @@ jobs:
name: '[CE] E2E (sqlite, node: ${{ matrix.node }})'
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
steps:
- uses: actions/checkout@v2
@ -161,7 +161,7 @@ jobs:
name: '[CE] E2E (mongo, node: ${{ matrix.node }})'
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
mongo:
@ -188,7 +188,7 @@ jobs:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
postgres:
@ -228,7 +228,7 @@ jobs:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
mysql:
@ -267,7 +267,7 @@ jobs:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
steps:
- uses: actions/checkout@v2
@ -289,7 +289,7 @@ jobs:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
matrix:
node: [12, 14]
node: [12, 14, 16]
max-parallel: 2
services:
mongo:

View File

@ -33,11 +33,8 @@
<a href="https://www.npmjs.org/package/strapi">
<img src="https://img.shields.io/npm/dm/strapi.svg" alt="Monthly download on NPM" />
</a>
<a href="https://travis-ci.org/strapi/strapi">
<img src="https://travis-ci.org/strapi/strapi.svg?branch=master" alt="Travis Build Status" />
</a>
<a href="https://slack.strapi.io">
<img src="https://slack.strapi.io/badge.svg" alt="Strapi on Slack" />
<a href="https://discord.strapi.io">
<img src="https://img.shields.io/discord/811989166782021633?label=Discord" alt="Strapi on Discord" />
</a>
</p>
@ -100,16 +97,16 @@ Complete installation requirements can be found in the documentation under <a hr
**Node:**
- NodeJS >= 10.16 <=14
- NodeJS >= 12 <= 16
- NPM >= 6.x
**Database:**
- MongoDB >= 3.6
- MySQL >= 5.6
- MariaDB >= 10.1
- PostgreSQL >= 10
- SQLite >= 3
- MongoDB >= 3.6 ([please read this thread before using MongoDB](https://forum.strapi.io/t/mongodb-compatibility-delayed-on-v4/4549/108))
**We recommend always using the latest version of Strapi to start your new projects**.
@ -121,7 +118,7 @@ Complete installation requirements can be found in the documentation under <a hr
- **Blazing Fast:** Built on top of Node.js, Strapi delivers amazing performance.
- **Front-end Agnostic:** Use any front-end framework (React, Vue, Angular, etc.), mobile apps or even IoT.
- **Powerful CLI:** Scaffold projects and APIs on the fly.
- **SQL & NoSQL databases:** Works with MongoDB, PostgreSQL, MySQL, MariaDB, and SQLite.
- **SQL & NoSQL databases:** Works with MongoDB ([please read this thread before using MongoDB](https://forum.strapi.io/t/mongodb-compatibility-delayed-on-v4/4549/108)), PostgreSQL, MySQL, MariaDB, and SQLite.
**[See more on our website](https://strapi.io/overview)**.
@ -133,7 +130,7 @@ Please read our [Contributing Guide](./CONTRIBUTING.md) before submitting a Pull
For general help using Strapi, please refer to [the official Strapi documentation](https://strapi.io/documentation/). For additional help, you can use one of these channels to ask a question:
- [Slack](https://slack.strapi.io) (For live discussion with the Community and Strapi team)
- [Discord](https://discord.strapi.io) (For live discussion with the Community and Strapi team)
- [GitHub](https://github.com/strapi/strapi) (Bug reports, Contributions)
- [Community Forum](https://forum.strapi.io) (Questions and Discussions)
- [Academy](https://academy.strapi.io) (Learn the fundamentals of Strapi)

View File

@ -2,19 +2,12 @@
## Supported Versions
As of May 2020 (and until this document is updated), only the v3.0.0 *stable* release of Strapi is supported for updates. Any previous versions are currently not supported and users are advised to use them "at their own risk".
As of May 2020 (and until this document is updated), only the v3.0.0 _stable_ release of Strapi is supported for updates. Any previous versions are currently not supported and users are advised to use them "at their own risk".
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to
**[security@strapi.io](mailto:security@strapi.io)** or via the [Strapi Slack](https://slack.strapi.io).
When reporting a (suspected) security vulnerability via slack please reach out to any of the following Strapi employees directly:
- `@aureliengeorget`
- `@alexandre`
- `@lauriejim`
- `@soupette`
**[security@strapi.io](mailto:security@strapi.io)**.
You will receive a response from us within 72 hours. If the issue is confirmed,
we will release a patch as soon as possible depending on complexity

View File

@ -0,0 +1,15 @@
'use strict';
module.exports = {
webpack: (config, webpack) => {
// Note: we provide webpack above so you should not `require` it
// Perform customizations to webpack config
// Important: return the modified config
return config;
},
app: config => {
config.locales = ['fr'];
return config;
},
};

View File

@ -14,9 +14,19 @@
],
"comment": ""
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"postal_coder": {
"type": "string"
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"categories": {
"collection": "category",
@ -32,7 +42,12 @@
"videos"
],
"plugin": "upload",
"required": false
"required": false,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"images": {
"collection": "file",
@ -41,22 +56,71 @@
"images"
],
"plugin": "upload",
"required": false
"required": false,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"city": {
"type": "string",
"required": true,
"maxLength": 200
"maxLength": 200,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"likes": {
"collection": "like",
"via": "address"
},
"json": {
"type": "json"
"type": "json",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid"
},
"notrepeat_req": {
"type": "component",
"repeatable": false,
"pluginOptions": {
"i18n": {
"localized": false
}
},
"component": "blog.test-como",
"required": true
},
"repeat_req": {
"type": "component",
"repeatable": true,
"pluginOptions": {
"i18n": {
"localized": true
}
},
"component": "blog.test-como",
"required": true
},
"repeat_req_min": {
"type": "component",
"repeatable": true,
"pluginOptions": {
"i18n": {
"localized": true
}
},
"component": "blog.test-como",
"required": false,
"min": 2
}
}
}

View File

@ -1,7 +1,7 @@
{
"name": "getstarted",
"private": true,
"version": "3.6.0",
"version": "3.6.5",
"description": "A Strapi application.",
"scripts": {
"develop": "strapi develop",
@ -17,29 +17,29 @@
"mysql": "2.18.1",
"pg": "8.6.0",
"sqlite3": "5.0.0",
"@strapi/strapi": "3.6.0",
"@strapi/admin": "3.6.0",
"@strapi/connector-bookshelf": "3.6.0",
"@strapi/connector-mongoose": "3.6.0",
"strapi-middleware-views": "3.6.0",
"@strapi/plugin-content-manager": "3.6.0",
"@strapi/plugin-content-type-builder": "3.6.0",
"@strapi/plugin-documentation": "3.6.0",
"@strapi/plugin-email": "3.6.0",
"@strapi/plugin-graphql": "3.6.0",
"@strapi/plugin-i18n": "3.6.0",
"@strapi/plugin-upload": "3.6.0",
"@strapi/plugin-users-permissions": "3.6.0",
"@strapi/provider-email-mailgun": "3.6.0",
"@strapi/provider-upload-aws-s3": "3.6.0",
"@strapi/provider-upload-cloudinary": "3.6.0",
"@strapi/utils": "3.6.0"
"@strapi/strapi": "3.6.5",
"@strapi/admin": "3.6.5",
"@strapi/connector-bookshelf": "3.6.5",
"@strapi/connector-mongoose": "3.6.5",
"strapi-middleware-views": "3.6.5",
"@strapi/plugin-content-manager": "3.6.5",
"@strapi/plugin-content-type-builder": "3.6.5",
"@strapi/plugin-documentation": "3.6.5",
"@strapi/plugin-email": "3.6.5",
"@strapi/plugin-graphql": "3.6.5",
"@strapi/plugin-i18n": "3.6.5",
"@strapi/plugin-upload": "3.6.5",
"@strapi/plugin-users-permissions": "3.6.5",
"@strapi/provider-email-mailgun": "3.6.5",
"@strapi/provider-upload-aws-s3": "3.6.5",
"@strapi/provider-upload-cloudinary": "3.6.5",
"@strapi/utils": "3.6.5"
},
"strapi": {
"uuid": "getstarted"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"license": "SEE LICENSE IN LICENSE"

View File

@ -1,3 +1,4 @@
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginPkg from '../../package.json';
import pluginId from './pluginId';
@ -7,31 +8,47 @@ const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'My plugin',
},
Component: () => 'My plugin',
permissions: [],
});
app.registerPlugin({
description: pluginDescription,
icon,
id: pluginId,
isReady: true,
isRequired: pluginPkg.strapi.required || false,
mainComponent: () => 'My plugin',
name,
settings: null,
trads: {},
menu: {
pluginsSectionLinks: [
{
destination: `/plugins/${pluginId}`,
icon,
label: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'My plugin',
},
name,
permissions: null,
},
],
},
});
},
boot() {},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {
return import(
/* webpackChunkName: "[pluginId]-[request]" */ `./translations/${locale}.json`
)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
};

View File

@ -0,0 +1,9 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import getTrad from '../../utils/getTrad';
const App = () => {
return <FormattedMessage id={getTrad('plugin.name')} defaultMessage="My plugin" />;
};
export default App;

View File

@ -0,0 +1,3 @@
{
"plugin.name": "My plugin"
}

View File

@ -0,0 +1,5 @@
import pluginId from '../pluginId';
const getTrad = id => `${pluginId}.${id}`;
export default getTrad;

View File

@ -54,7 +54,10 @@ module.exports = {
],
moduleNameMapper,
rootDir: process.cwd(),
setupFiles: ['<rootDir>/test/config/front/test-bundler.js'],
setupFiles: [
'<rootDir>/test/config/front/test-bundler.js',
'<rootDir>/packages/admin-test-utils/lib/mocks/LocalStorageMock.js',
],
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/examples/getstarted/',

View File

@ -1,5 +1,5 @@
{
"version": "3.6.0",
"version": "3.6.5",
"packages": [
"packages/*",
"examples/*"

View File

@ -2,9 +2,10 @@
"private": true,
"devDependencies": {
"@swc-node/jest": "^1.1.0",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "13.1.9",
"axios-mock-adapter": "^1.19.0",
"babel-eslint": "^10.0.0",
"chalk": "4.1.1",
@ -12,7 +13,7 @@
"cross-env": "^7.0.3",
"enzyme": "^3.9.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.24.0",
"eslint": "^7.25.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^6.15.0",
@ -43,7 +44,7 @@
"request-promise-native": "^1.0.9",
"rimraf": "3.0.2",
"snyk": "^1.566.0",
"stylelint": "13.12.0",
"stylelint": "13.13.1",
"stylelint-config-recommended": "3.0.0",
"stylelint-config-styled-components": "0.1.1",
"stylelint-processor-styled-components": "1.10.0",
@ -98,7 +99,7 @@
"url": "https://github.com/strapi/strapi/issues"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"license": "SEE LICENSE IN LICENSE",

View File

@ -0,0 +1,16 @@
'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

@ -0,0 +1,125 @@
'use strict';
const adminPermissions = [
{
id: 169,
action: 'admin::provider-login.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 170,
action: 'admin::provider-login.update',
subject: null,
properties: {},
conditions: [],
},
{
id: 171,
action: 'admin::marketplace.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 172,
action: 'admin::marketplace.plugins.install',
subject: null,
properties: {},
conditions: [],
},
{
id: 173,
action: 'admin::marketplace.plugins.uninstall',
subject: null,
properties: {},
conditions: [],
},
{
id: 174,
action: 'admin::webhooks.create',
subject: null,
properties: {},
conditions: [],
},
{
id: 175,
action: 'admin::webhooks.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 176,
action: 'admin::webhooks.update',
subject: null,
properties: {},
conditions: [],
},
{
id: 177,
action: 'admin::webhooks.delete',
subject: null,
properties: {},
conditions: [],
},
{
id: 178,
action: 'admin::users.create',
subject: null,
properties: {},
conditions: [],
},
{
id: 179,
action: 'admin::users.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 180,
action: 'admin::users.update',
subject: null,
properties: {},
conditions: [],
},
{
id: 181,
action: 'admin::users.delete',
subject: null,
properties: {},
conditions: [],
},
{
id: 182,
action: 'admin::roles.create',
subject: null,
properties: {},
conditions: [],
},
{
id: 183,
action: 'admin::roles.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 184,
action: 'admin::roles.update',
subject: null,
properties: {},
conditions: [],
},
{
id: 185,
action: 'admin::roles.delete',
subject: null,
properties: {},
conditions: [],
},
];
module.exports = adminPermissions;

View File

@ -0,0 +1,63 @@
'use strict';
const cmPermissions = [
{
id: 2817,
action: 'plugins::content-manager.single-types.configure-view',
subject: null,
properties: {},
conditions: [],
},
{
id: 2818,
action: 'plugins::content-manager.collection-types.configure-view',
subject: null,
properties: {},
conditions: [],
},
{
id: 2819,
action: 'plugins::content-manager.components.configure-layout',
subject: null,
properties: {},
conditions: [],
},
{
action: 'plugins::content-manager.explorer.create',
subject: 'foo',
properties: {
fields: ['f1'],
},
conditions: [],
},
{
action: 'plugins::content-manager.explorer.create',
subject: 'foo',
properties: {
fields: ['f2'],
},
conditions: [],
},
{
action: 'plugins::content-manager.explorer.read',
subject: 'foo',
properties: {
fields: ['f1'],
},
conditions: [],
},
{
action: 'plugins::content-manager.explorer.delete',
subject: 'bar',
},
{
action: 'plugins::content-manager.explorer.update',
subject: 'bar',
properties: {
fields: ['f1'],
},
conditions: [],
},
];
module.exports = cmPermissions;

View File

@ -0,0 +1,42 @@
'use strict';
const ctbPermissions = [
{
id: 2820,
action: 'plugins::content-type-builder.read',
subject: null,
properties: {},
conditions: [],
},
{ id: 2821, action: 'plugins::upload.read', subject: null, properties: {}, conditions: [] },
{
id: 2822,
action: 'plugins::upload.assets.create',
subject: null,
properties: {},
conditions: [],
},
{
id: 2823,
action: 'plugins::upload.assets.update',
subject: null,
properties: {},
conditions: [],
},
{
id: 2824,
action: 'plugins::upload.assets.download',
subject: null,
properties: {},
conditions: [],
},
{
id: 2825,
action: 'plugins::upload.assets.copy-link',
subject: null,
properties: {},
conditions: [],
},
];
module.exports = ctbPermissions;

View File

@ -0,0 +1,15 @@
'use strict';
// eslint-disable-next-line node/no-extraneous-require
const { combineReducers, createStore } = require('redux');
const reducers = {
rbacProvider: jest.fn(() => ({ allPermissions: null, collectionTypesRelatedPermissions: {} })),
};
const store = createStore(combineReducers(reducers));
module.exports = {
store,
state: store.getState(),
};

View File

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

View File

@ -0,0 +1,25 @@
'use strict';
class LocalStorageMock {
constructor() {
this.store = {};
}
clear() {
this.store = {};
}
getItem(key) {
return this.store[key] || null;
}
setItem(key, value) {
this.store[key] = String(value);
}
removeItem(key) {
delete this.store[key];
}
}
global.localStorage = new LocalStorageMock();

View File

@ -0,0 +1,12 @@
{
"name": "@strapi/admin-test-utils",
"version": "1.0.0",
"private": true,
"description": "Test utilities for the Strapi administration panel",
"main": "lib/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Strapi Team",
"license": "MIT"
}

View File

@ -2,15 +2,14 @@
const commander = require('commander');
const generateNewApp = require('@strapi/generate-new');
const promptUser = require('./utils/prompt-user');
const packageJson = require('./package.json');
const program = new commander.Command(packageJson.name);
let projectName;
program
.version(packageJson.version)
.arguments('<directory>')
.arguments('[directory]')
.option('--no-run', 'Do not start the application after it is created')
.option('--use-npm', 'Force usage of npm instead of yarn to create the project')
.option('--debug', 'Display database connection error')
@ -29,19 +28,41 @@ program
.option('--template <templateurl>', 'Specify a Strapi template')
.description('create a new application')
.action(directory => {
projectName = directory;
initProject(directory, program);
})
.parse(process.argv);
if (projectName === undefined) {
console.error('Please specify the <directory> of your project');
function generateApp(projectName, options) {
if (!projectName) {
console.error('Please specify the <directory> of your project when using --quickstart');
// eslint-disable-next-line no-process-exit
process.exit(1);
}
// eslint-disable-next-line no-process-exit
process.exit(1);
return generateNewApp(projectName, options).then(() => {
if (process.platform === 'win32') {
process.exit(0);
}
});
}
generateNewApp(projectName, program).then(() => {
if (process.platform === 'win32') {
process.exit(0);
async function initProject(projectName, program) {
if (program.quickstart) {
return generateApp(projectName, program);
}
});
const prompt = await promptUser(projectName, program.template);
const directory = prompt.directory || projectName;
const options = {
template: prompt.template || program.template,
quickstart: prompt.quick,
};
const generateStrapiAppOptions = {
...program,
...options,
};
return generateApp(directory, generateStrapiAppOptions);
}

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi-app",
"version": "3.6.0",
"version": "3.6.5",
"description": "Generate a new Strapi application.",
"license": "SEE LICENSE IN LICENSE",
"homepage": "https://strapi.io",
@ -15,13 +15,13 @@
"bin": {
"create-strapi-app": "./index.js"
},
"files": [
"create-strapi-app.js",
"index.js"
],
"dependencies": {
"chalk": "4.1.1",
"commander": "6.1.0",
"@strapi/generate-new": "3.6.0"
"inquirer": "8.1.0",
"js-yaml": "4.1.0",
"node-fetch": "^2.6.1",
"@strapi/generate-new": "3.6.5"
},
"scripts": {
"test": "echo \"no tests yet\""
@ -39,7 +39,7 @@
"url": "https://github.com/strapi/strapi/issues"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"gitHead": "231263a3535658bab1e9492c6aaaed8692d62a53"

View File

@ -0,0 +1,114 @@
'use strict';
const inquirer = require('inquirer');
const fetch = require('node-fetch');
const yaml = require('js-yaml');
/**
* @param {string|null} projectName - The name/path of project
* @param {string|null} template - The Github repo of the template
* @returns Object containting prompt answers
*/
module.exports = async function promptUser(projectName, template) {
const questions = await getPromptQuestions(projectName, template);
const [initialResponse, templateQuestion] = await Promise.all([
inquirer.prompt(questions),
getTemplateQuestion(),
]);
if (initialResponse.useTemplate) {
const updatedResponse = await inquirer.prompt(templateQuestion);
return { ...initialResponse, ...updatedResponse };
}
return initialResponse;
};
/**
*
* @returns Prompt question object
*/
async function getTemplateQuestion() {
const content = await getTemplateData();
// Fallback to manual input when fetch fails
if (!content) {
return {
name: 'template',
type: 'input',
message: 'Please provide the GitHub URL for your template:',
};
}
const choices = content.map(option => {
const name = option.title.replace('Template', '');
return {
name,
value: `https://github.com/${option.repo}`,
};
});
return {
name: 'template',
type: 'list',
message: `Select a template`,
pageSize: choices.length,
choices,
};
}
/**
*
* @returns Array of prompt question objects
*/
async function getPromptQuestions(projectName, template) {
return [
{
type: 'input',
default: 'my-strapi-project',
name: 'directory',
message: 'What would you like to name your project?',
when: !projectName,
},
{
type: 'list',
name: 'quick',
message: 'Choose your installation type',
choices: [
{
name: 'Quickstart (recommended)',
value: true,
},
{
name: 'Custom (manual settings)',
value: false,
},
],
},
{
type: 'confirm',
name: 'useTemplate',
when: !template,
message:
'Would you like to use a template? (Templates are Strapi configurations designed for a specific use case)',
},
];
}
/**
*
* @returns JSON template data
*/
async function getTemplateData() {
const response = await fetch(
`https://api.github.com/repos/strapi/community-content/contents/templates/templates.yml`
);
if (!response.ok) {
return null;
}
const { content } = await response.json();
const buff = Buffer.from(content, 'base64');
const stringified = buff.toString('utf-8');
return yaml.load(stringified);
}

View File

@ -4,12 +4,13 @@ const commander = require('commander');
const packageJson = require('./package.json');
const buildStarter = require('./utils/build-starter');
const promptUser = require('./utils/prompt-user');
const program = new commander.Command(packageJson.name);
program
.version(packageJson.version)
.arguments('<directory> <starterurl>')
.arguments('[directory], [starterurl]')
.option('--use-npm', 'Force usage of npm instead of yarn to create the project')
.option('--debug', 'Display database connection error')
.option('--quickstart', 'Quickstart app creation')
@ -30,13 +31,41 @@ program
.action((directory, starterUrl, programArgs) => {
const projectArgs = { projectName: directory, starterUrl };
buildStarter(projectArgs, programArgs).catch(error => {
console.error(error.message);
process.exit(1);
});
initProject(projectArgs, programArgs);
});
program.exitOverride();
function generateApp(projectArgs, programArgs) {
if (!projectArgs.projectName || !projectArgs.starterUrl) {
console.error(
'Please specify the <directory> and <starterurl> of your project when using --quickstart'
);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
return buildStarter(projectArgs, programArgs);
}
async function initProject(projectArgs, program) {
const { projectName, starterUrl } = projectArgs;
if (program.quickstart) {
return generateApp(projectArgs, program);
}
const prompt = await promptUser(projectName, starterUrl);
const promptProjectArgs = {
projectName: prompt.directory || projectName,
starterUrl: prompt.starter || starterUrl,
};
const programArgs = {
...program,
quickstart: prompt.quick,
};
return generateApp(promptProjectArgs, programArgs);
}
try {
program.parse(process.argv);

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi-starter",
"version": "3.6.0",
"version": "3.6.5",
"description": "Generate a new Strapi application.",
"license": "SEE LICENSE IN LICENSE",
"homepage": "https://strapi.io",
@ -22,9 +22,11 @@
"execa": "5.0.0",
"fs-extra": "9.1.0",
"git-url-parse": "11.4.4",
"node-fetch": "2.6.1",
"ora": "5.3.0",
"@strapi/generate-new": "3.6.0",
"inquirer": "8.1.0",
"js-yaml": "4.1.0",
"node-fetch": "^2.6.1",
"ora": "5.4.0",
"@strapi/generate-new": "3.6.5",
"tar": "6.1.0"
},
"scripts": {
@ -43,7 +45,7 @@
"url": "https://github.com/strapi/strapi/issues"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"gitHead": "231263a3535658bab1e9492c6aaaed8692d62a53"

View File

@ -12,12 +12,12 @@ const generateNewApp = require('@strapi/generate-new');
const hasYarn = require('./has-yarn');
const { runInstall, runApp, initGit } = require('./child-process');
const { getRepoInfo, downloadGithubRepo } = require('./fetch-github');
const { getRepoInfo, downloadGitHubRepo } = require('./fetch-github');
const logger = require('./logger');
const stopProcess = require('./stop-process');
/**
* @param {string} filePath Path to starter.json file
* @param {string} - filePath Path to starter.json file
*/
function readStarterJson(filePath, starterUrl) {
try {
@ -29,11 +29,11 @@ function readStarterJson(filePath, starterUrl) {
}
/**
* @param {string} rootPath Path to the project directory
* @param {string} projectName Name of the project
* @param {string} rootPath - Path to the project directory
* @param {string} projectName - Name of the project
*/
async function initPackageJson(rootPath, projectName) {
const packageManager = hasYarn ? 'yarn --cwd' : 'npm run --prefix';
const packageManager = hasYarn() ? 'yarn --cwd' : 'npm run --prefix';
try {
await fse.writeJson(
@ -63,7 +63,7 @@ async function initPackageJson(rootPath, projectName) {
}
/**
* @param {string} path The directory path for install
* @param {string} path - The directory path for install
*/
async function installWithLogs(path) {
const installPrefix = chalk.yellow('Installing dependencies:');
@ -86,20 +86,23 @@ async function installWithLogs(path) {
}
/**
* @param {object} projectArgs projectName and starterUrl for the project
* @param {object} program Commands for generating new application
* @param {Object} projectArgs - The arguments for create a project
* @param {string|null} projectArgs.projectName - The name/path of project
* @param {string|null} projectArgs.starterUrl - The GitHub repo of the starter
* @param {Object} program - Commands for generating new application
*/
module.exports = async function buildStarter(projectArgs, program) {
const { projectName, starterUrl } = projectArgs;
module.exports = async function buildStarter(programArgs, program) {
let { projectName, starterUrl } = programArgs;
// Fetch repo info
const repoInfo = await getRepoInfo(starterUrl);
const { fullName } = repoInfo;
// Create temporary directory for starter
const tmpDir = await fse.mkdtemp(join(os.tmpdir(), 'strapi-'));
// Fetch repo info
const { full_name } = await getRepoInfo(starterUrl);
// Download repo inside tmp dir
await downloadGithubRepo(starterUrl, tmpDir);
// Download repo inside temporary directory
await downloadGitHubRepo(repoInfo, tmpDir);
const starterJson = readStarterJson(join(tmpDir, 'starter.json'), starterUrl);
@ -130,14 +133,7 @@ module.exports = async function buildStarter(projectArgs, program) {
// Delete temporary directory
await fse.remove(tmpDir);
console.log(`Creating Strapi starter frontend at ${chalk.yellow(frontendPath)}.`);
// Install frontend dependencies
console.log(`Installing ${chalk.yellow(full_name)} starter`);
await installWithLogs(frontendPath);
const fullUrl = `https://github.com/${full_name}`;
const fullUrl = `https://github.com/${fullName}`;
// Set command options for Strapi app
const generateStrapiAppOptions = {
...program,
@ -149,6 +145,11 @@ module.exports = async function buildStarter(projectArgs, program) {
// Create strapi app using the template
await generateNewApp(join(rootPath, 'backend'), generateStrapiAppOptions);
// Install frontend dependencies
console.log(`Creating Strapi starter frontend at ${chalk.green(frontendPath)}.`);
console.log(`Installing ${chalk.yellow(fullName)} starter`);
await installWithLogs(frontendPath);
// Setup monorepo
initPackageJson(rootPath, projectBasename);

View File

@ -9,7 +9,7 @@ const logger = require('./logger');
* @param {string} path Path to directory (frontend, backend)
*/
function runInstall(path) {
if (hasYarn) {
if (hasYarn()) {
return execa('yarn', ['install'], {
cwd: path,
stdin: 'ignore',
@ -20,7 +20,7 @@ function runInstall(path) {
}
function runApp(rootPath) {
if (hasYarn) {
if (hasYarn()) {
return execa('yarn', ['develop'], {
stdio: 'inherit',
cwd: rootPath,

View File

@ -4,25 +4,29 @@ const tar = require('tar');
const fetch = require('node-fetch');
const parseGitUrl = require('git-url-parse');
const chalk = require('chalk');
const stopProcess = require('./stop-process');
function getShortcut(starter) {
let full_name;
// Determine if it is another organization
function parseShorthand(starter) {
// Determine if it is comes from another owner
if (starter.includes('/')) {
const [org, project] = starter.split('/');
full_name = `${org}/strapi-starter-${project}`;
} else {
full_name = `strapi/strapi-starter-${starter}`;
const [owner, partialName] = starter.split('/');
const name = `strapi-starter-${partialName}`;
return {
name,
fullName: `${owner}/${name}`,
};
}
const name = `strapi-starter-${starter}`;
return {
full_name,
usedShortcut: true,
name,
fullName: `strapi/${name}`,
};
}
/**
* @param {string} repo The path to repo
* @param {string} repo The full name of the repository.
*/
async function getDefaultBranch(repo) {
const response = await fetch(`https://api.github.com/repos/${repo}`);
@ -36,49 +40,53 @@ async function getDefaultBranch(repo) {
}
const { default_branch } = await response.json();
return default_branch;
}
/**
* @param {string} starterUrl Github url to starter project
* @param {string} starter GitHub URL or shorthand to a starter project.
*/
async function getRepoInfo(starter) {
const repoInfo = await parseGitUrl(starter);
const { name, full_name, ref, protocols, source } = repoInfo;
const { name, full_name: fullName, ref, filepath, protocols, source } = parseGitUrl(starter);
if (protocols.length === 0) {
return getShortcut(starter);
const repoInfo = parseShorthand(starter);
return {
...repoInfo,
branch: await getDefaultBranch(repoInfo.fullName),
usedShorthand: true,
};
}
if (source !== 'github.com') {
stopProcess(`Github URL not found for: ${chalk.yellow(starter)}`);
stopProcess(`GitHub URL not found for: ${chalk.yellow(starter)}.`);
}
return {
name,
full_name,
ref,
};
let branch;
if (ref) {
// Append the filepath to the parsed ref since a branch name could contain '/'
// If so, the rest of the branch name will be considered 'filepath' by 'parseGitUrl'
branch = filepath ? `${ref}/${filepath}` : ref;
} else {
branch = await getDefaultBranch(fullName);
}
return { name, fullName, branch };
}
/**
* @param {string} starterUrl Github url for strapi starter
* @param {string} tmpDir Path to temporary directory
* @param {string} repoInfo GitHub repository information (full name, branch...).
* @param {string} tmpDir Path to the destination temporary directory.
*/
async function downloadGithubRepo(starterUrl, tmpDir) {
const { full_name, ref, usedShortcut } = await getRepoInfo(starterUrl);
const default_branch = await getDefaultBranch(full_name);
const branch = ref ? ref : default_branch;
// Download from GitHub
const codeload = `https://codeload.github.com/${full_name}/tar.gz/${branch}`;
async function downloadGitHubRepo(repoInfo, tmpDir) {
const { fullName, branch, usedShorthand } = repoInfo;
const codeload = `https://codeload.github.com/${fullName}/tar.gz/${branch}`;
const response = await fetch(codeload);
if (!response.ok) {
const message = usedShortcut ? `using the shortcut` : `using the url`;
stopProcess(`Could not download the repository ${message}: ${chalk.yellow(`${starterUrl}`)}`);
const message = usedShorthand ? `using the shorthand` : `using the url`;
stopProcess(`Could not download the repository ${message}: ${chalk.yellow(fullName)}.`);
}
await new Promise(resolve => {
@ -86,4 +94,4 @@ async function downloadGithubRepo(starterUrl, tmpDir) {
});
}
module.exports = { getRepoInfo, downloadGithubRepo };
module.exports = { getRepoInfo, downloadGitHubRepo };

View File

@ -4,8 +4,9 @@ const execa = require('execa');
module.exports = function hasYarn() {
try {
const { code } = execa.shellSync('yarnpkg --version');
if (code === 0) return true;
const { exitCode } = execa.sync('yarn --version', { shell: true });
if (exitCode === 0) return true;
return false;
} catch (err) {
return false;

View File

@ -0,0 +1,103 @@
'use strict';
const inquirer = require('inquirer');
const fetch = require('node-fetch');
const yaml = require('js-yaml');
/**
* @param {string|null} projectName - The name/path of project
* @param {string|null} starterUrl - The GitHub repo of the starter
* @returns Object containting prompt answers
*/
module.exports = async function promptUser(projectName, starter) {
const mainQuestions = [
{
type: 'input',
default: 'my-strapi-project',
name: 'directory',
message: 'What would you like to name your project?',
when: !projectName,
},
{
type: 'list',
name: 'quick',
message: 'Choose your installation type',
choices: [
{
name: 'Quickstart (recommended)',
value: true,
},
{
name: 'Custom (manual settings)',
value: false,
},
],
},
];
const [mainResponse, starterQuestion] = await Promise.all([
inquirer.prompt(mainQuestions),
getStarterQuestion(),
]);
const starterResponse = await inquirer.prompt({
name: 'starter',
when: !starter,
...starterQuestion,
});
return { ...mainResponse, ...starterResponse };
};
/**
*
* @returns Prompt question object
*/
async function getStarterQuestion() {
const content = await getStarterData();
// Fallback to manual input when fetch fails
if (!content) {
return {
type: 'input',
message: 'Please provide the GitHub URL for the starter you would like to use:',
};
}
const choices = content.map(option => {
const name = option.title.replace('Starter', '');
return {
name,
value: `https://github.com/${option.repo}`,
};
});
return {
type: 'list',
message:
'Which starter would you like to use? (Starters are fullstack Strapi applications designed for a specific use case)',
pageSize: choices.length,
choices,
};
}
/**
*
* @returns JSON starter data
*/
async function getStarterData() {
const response = await fetch(
`https://api.github.com/repos/strapi/community-content/contents/starters/starters.yml`
);
if (!response.ok) {
return null;
}
const { content } = await response.json();
const buff = Buffer.from(content, 'base64');
const stringified = buff.toString('utf-8');
return yaml.load(stringified);
}

View File

@ -5,7 +5,7 @@ const { keys, each, prop, isEmpty } = require('lodash/fp');
const { singular } = require('pluralize');
const { toQueries, runPopulateQueries } = require('./utils/populate-queries');
const BOOLEAN_OPERATORS = ['or'];
const BOOLEAN_OPERATORS = ['or', 'and'];
/**
* Build filters on a bookshelf query
@ -296,7 +296,7 @@ const buildJoinsAndFilter = (qb, model, filters) => {
* @param {Object} options.value - Filter value
*/
const buildWhereClause = ({ qb, field, operator, value }) => {
if (Array.isArray(value) && !['or', 'in', 'nin'].includes(operator)) {
if (Array.isArray(value) && !['and', 'or', 'in', 'nin'].includes(operator)) {
return qb.where(subQb => {
for (let val of value) {
subQb.orWhere(q => buildWhereClause({ qb: q, field, operator, value: val }));
@ -305,6 +305,20 @@ const buildWhereClause = ({ qb, field, operator, value }) => {
}
switch (operator) {
case 'and':
return qb.where(andQb => {
value.forEach(andClause => {
andQb.where(subQb => {
if (Array.isArray(andClause)) {
andClause.forEach(clause =>
subQb.where(andQb => buildWhereClause({ qb: andQb, ...clause }))
);
} else {
buildWhereClause({ qb: subQb, ...andClause });
}
});
});
});
case 'or':
return qb.where(orQb => {
value.forEach(orClause => {

View File

@ -1,6 +1,6 @@
{
"name": "@strapi/connector-bookshelf",
"version": "3.6.0",
"version": "3.6.5",
"description": "Bookshelf hook for the Strapi framework",
"homepage": "https://strapi.io",
"keywords": [
@ -23,7 +23,7 @@
"p-map": "4.0.0",
"pluralize": "^8.0.0",
"rimraf": "3.0.2",
"@strapi/utils": "3.6.0"
"@strapi/utils": "3.6.5"
},
"peerDependencies": {
"knex": "0.21.18"
@ -56,7 +56,7 @@
"url": "https://github.com/strapi/strapi/issues"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"license": "SEE LICENSE IN LICENSE",

View File

@ -1,6 +1,6 @@
{
"name": "@strapi/connector-mongoose",
"version": "3.6.0",
"version": "3.6.5",
"description": "Mongoose hook for the Strapi framework",
"homepage": "https://strapi.io",
"keywords": [
@ -22,7 +22,7 @@
"p-map": "4.0.0",
"pluralize": "^8.0.0",
"semver": "^7.3.5",
"@strapi/utils": "3.6.0"
"@strapi/utils": "3.6.5"
},
"author": {
"email": "hi@strapi.io",
@ -44,7 +44,7 @@
"url": "https://github.com/strapi/strapi/issues"
},
"engines": {
"node": ">=10.16.0 <=14.x.x",
"node": ">=12.x.x <=16.x.x",
"npm": ">=6.0.0"
},
"license": "SEE LICENSE IN LICENSE",

View File

@ -4,6 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from 'react-query';
import { ThemeProvider } from 'styled-components';
import { LibraryProvider, StrapiAppProvider } from '@strapi/helper-plugin';
import pick from 'lodash/pick';
import createHook from '@strapi/hooks';
import invariant from 'invariant';
import configureStore from './core/store/configureStore';
import { Plugin } from './core/apis';
import basename from './utils/basename';
@ -12,13 +15,10 @@ import LanguageProvider from './components/LanguageProvider';
import AutoReloadOverlayBlockerProvider from './components/AutoReloadOverlayBlockerProvider';
import OverlayBlocker from './components/OverlayBlocker';
import Fonts from './components/Fonts';
import GlobalStyle from './components/GlobalStyle';
import Notifications from './components/Notifications';
import themes from './themes';
// TODO
import translations from './translations';
import languageNativeNames from './translations/languageNativeNames';
window.strapi = {
backendURL: process.env.STRAPI_ADMIN_BACKEND_URL,
@ -32,16 +32,27 @@ const queryClient = new QueryClient({
},
});
const appLocales = Object.keys(translations);
class StrapiApp {
constructor({ appPlugins, library, middlewares, reducers }) {
constructor({ appPlugins, library, locales, middlewares, reducers }) {
this.appLocales = ['en', ...locales.filter(loc => loc !== 'en')];
this.appPlugins = appPlugins || {};
this.library = library;
this.middlewares = middlewares;
this.plugins = {};
this.reducers = reducers;
this.translations = translations;
this.translations = {};
this.hooksDict = {};
this.menu = [];
this.settings = {
global: {
id: 'global',
intlLabel: {
id: 'Settings.global',
defaultMessage: 'Global Settings',
},
links: [],
},
};
}
addComponents = components => {
@ -52,6 +63,28 @@ class StrapiApp {
}
};
addCorePluginMenuLink = link => {
const stringifiedLink = JSON.stringify(link);
invariant(link.to, `link.to should be defined for ${stringifiedLink}`);
invariant(
typeof link.to === 'string',
`Expected link.to to be a string instead received ${typeof link.to}`
);
invariant(
['/plugins/content-manager', '/plugins/content-type-builder', '/plugins/upload'].includes(
link.to
),
'This method is not available for your plugin'
);
invariant(
link.intlLabel?.id && link.intlLabel?.defaultMessage,
`link.intlLabel.id & link.intlLabel.defaultMessage for ${stringifiedLink}`
);
this.menu.push(link);
};
addFields = fields => {
if (Array.isArray(fields)) {
fields.map(field => this.library.fields.add(field));
@ -60,6 +93,26 @@ class StrapiApp {
}
};
addMenuLink = link => {
const stringifiedLink = JSON.stringify(link);
invariant(link.to, `link.to should be defined for ${stringifiedLink}`);
invariant(
typeof link.to === 'string',
`Expected link.to to be a string instead received ${typeof link.to}`
);
invariant(
link.intlLabel?.id && link.intlLabel?.defaultMessage,
`link.intlLabel.id & link.intlLabel.defaultMessage for ${stringifiedLink}`
);
invariant(
link.Component && typeof link.Component === 'function',
`link.Component should be a valid React Component`
);
this.menu.push(link);
};
addMiddlewares = middlewares => {
middlewares.forEach(middleware => {
this.middlewares.add(middleware);
@ -72,13 +125,44 @@ class StrapiApp {
});
};
addSettingsLink = (sectionId, link) => {
invariant(this.settings[sectionId], 'The section does not exist');
const stringifiedLink = JSON.stringify(link);
invariant(link.id, `link.id should be defined for ${stringifiedLink}`);
invariant(
link.intlLabel?.id && link.intlLabel?.defaultMessage,
`link.intlLabel.id & link.intlLabel.defaultMessage for ${stringifiedLink}`
);
invariant(link.to, `link.to should be defined for ${stringifiedLink}`);
invariant(
link.Component && typeof link.Component === 'function',
`link.Component should be a valid React Component`
);
this.settings[sectionId].links.push(link);
};
addSettingsLinks = (sectionId, links) => {
invariant(this.settings[sectionId], 'The section does not exist');
invariant(Array.isArray(links), 'TypeError expected links to be an array');
links.forEach(link => {
this.addSettingsLink(sectionId, link);
});
};
async initialize() {
Object.keys(this.appPlugins).forEach(plugin => {
this.appPlugins[plugin].register({
addComponents: this.addComponents,
addCorePluginMenuLink: this.addCorePluginMenuLink,
addFields: this.addFields,
addMenuLink: this.addMenuLink,
addMiddlewares: this.addMiddlewares,
addReducers: this.addReducers,
createSettingSection: this.createSettingSection,
registerPlugin: this.registerPlugin,
});
});
@ -89,29 +173,87 @@ class StrapiApp {
const boot = this.appPlugins[plugin].boot;
if (boot) {
boot({ getPlugin: this.getPlugin });
boot({
addSettingsLink: this.addSettingsLink,
addSettingsLinks: this.addSettingsLinks,
getPlugin: this.getPlugin,
});
}
});
}
createSettingSection = (section, links) => {
invariant(section.id, 'section.id should be defined');
invariant(
section.intlLabel?.id && section.intlLabel?.defaultMessage,
'section.intlLabel should be defined'
);
invariant(Array.isArray(links), 'TypeError expected links to be an array');
invariant(this.settings[section.id] === undefined, 'A similar section already exists');
this.settings[section.id] = { ...section, links: [] };
links.forEach(link => {
this.addSettingsLink(section.id, link);
});
};
createStore = () => {
const store = configureStore(this.middlewares.middlewares, this.reducers.reducers);
return store;
};
getPlugin = pluginId => {
return this.plugins[pluginId];
};
// FIXME
registerPluginTranslations(pluginId, trads) {
const pluginTranslations = appLocales.reduce((acc, currentLanguage) => {
const currentLocale = trads[currentLanguage];
async loadAdminTrads() {
const arrayOfPromises = this.appLocales.map(locale => {
return import(/* webpackChunkName: "[request]" */ `./translations/${locale}.json`)
.then(({ default: data }) => {
return { data, locale };
})
.catch(() => {
return { data: {}, locale };
});
});
const adminLocales = await Promise.all(arrayOfPromises);
if (currentLocale) {
const localeprefixedWithPluginId = Object.keys(currentLocale).reduce((acc2, current) => {
acc2[`${pluginId}.${current}`] = currentLocale[current];
this.translations = adminLocales.reduce((acc, current) => {
acc[current.locale] = current.data;
return acc2;
}, {});
return acc;
}, {});
acc[currentLanguage] = localeprefixedWithPluginId;
}
return Promise.resolve();
}
async loadTrads() {
const arrayOfPromises = Object.keys(this.appPlugins)
.map(plugin => {
const registerTrads = this.appPlugins[plugin].registerTrads;
if (registerTrads) {
return registerTrads({ locales: this.appLocales });
}
return null;
})
.filter(a => a);
const pluginsTrads = await Promise.all(arrayOfPromises);
const mergedTrads = pluginsTrads.reduce((acc, currentPluginTrads) => {
const pluginTrads = currentPluginTrads.reduce((acc1, current) => {
acc1[current.locale] = current.data;
return acc1;
}, {});
Object.keys(pluginTrads).forEach(locale => {
acc[locale] = { ...acc[locale], ...pluginTrads[locale] };
});
return acc;
}, {});
@ -119,26 +261,43 @@ class StrapiApp {
this.translations = Object.keys(this.translations).reduce((acc, current) => {
acc[current] = {
...this.translations[current],
...(pluginTranslations[current] || {}),
...(mergedTrads[current] || {}),
};
return acc;
}, {});
return Promise.resolve();
}
registerPlugin = pluginConf => {
// FIXME
// Translations should be loaded differently
// This is a temporary fix
this.registerPluginTranslations(pluginConf.id, pluginConf.trads);
const plugin = Plugin(pluginConf);
this.plugins[plugin.pluginId] = plugin;
};
createHook = name => {
this.hooksDict[name] = createHook();
};
registerHook = (name, fn) => {
this.hooksDict[name].register(fn);
};
runHookSeries = (name, asynchronous = false) =>
asynchronous ? this.hooksDict[name].runSeriesAsync() : this.hooksDict[name].runSeries();
runHookWaterfall = (name, initialValue, asynchronous = false) =>
asynchronous
? this.hooksDict[name].runWaterfallAsync(initialValue)
: this.hooksDict[name].runWaterfall(initialValue);
runHookParallel = name => this.hooksDict[name].runParallel();
render() {
const store = configureStore(this.middlewares.middlewares, this.reducers.reducers);
const store = this.createStore();
const localeNames = pick(languageNativeNames, this.appLocales);
const {
components: { components },
fields: { fields },
@ -150,9 +309,17 @@ class StrapiApp {
<GlobalStyle />
<Fonts />
<Provider store={store}>
<StrapiAppProvider getPlugin={this.getPlugin} plugins={this.plugins}>
<StrapiAppProvider
getPlugin={this.getPlugin}
menu={this.menu}
plugins={this.plugins}
runHookParallel={this.runHookParallel}
runHookWaterfall={this.runHookWaterfall}
runHookSeries={this.runHookSeries}
settings={this.settings}
>
<LibraryProvider components={components} fields={fields}>
<LanguageProvider messages={this.translations}>
<LanguageProvider messages={this.translations} localeNames={localeNames}>
<AutoReloadOverlayBlockerProvider>
<OverlayBlocker>
<Notifications>
@ -172,5 +339,5 @@ class StrapiApp {
}
}
export default ({ appPlugins, library, middlewares, reducers }) =>
new StrapiApp({ appPlugins, library, middlewares, reducers });
export default ({ appPlugins, library, locales, middlewares, reducers }) =>
new StrapiApp({ appPlugins, library, locales, middlewares, reducers });

View File

@ -0,0 +1,7 @@
module.exports = {
app: config => {
config.locales = ['fr'];
return config;
},
};

View File

@ -0,0 +1,65 @@
import React, { useMemo } from 'react';
import { LoadingIndicatorPage, AppInfosContext } from '@strapi/helper-plugin';
import { useQueries } from 'react-query';
import packageJSON from '../../../../package.json';
import PluginsInitializer from '../PluginsInitializer';
import RBACProvider from '../RBACProvider';
import { fetchAppInfo, fetchCurrentUserPermissions, fetchStrapiLatestRelease } from './utils/api';
import checkLatestStrapiVersion from './utils/checkLatestStrapiVersion';
const { STRAPI_ADMIN_UPDATE_NOTIFICATION } = process.env;
const canFetchRelease = STRAPI_ADMIN_UPDATE_NOTIFICATION === 'true';
const strapiVersion = packageJSON.version;
const AuthenticatedApp = () => {
const [
{ data: appInfos, status },
{ data: tag_name, isLoading },
{ data: permissions, status: fetchPermissionsStatus, refetch, isFetched, isFetching },
] = useQueries([
{ queryKey: 'app-infos', queryFn: fetchAppInfo },
{
queryKey: 'strapi-release',
queryFn: fetchStrapiLatestRelease,
enabled: canFetchRelease,
initialData: strapiVersion,
},
{
queryKey: 'admin-users-permission',
queryFn: fetchCurrentUserPermissions,
initialData: [],
},
]);
const shouldUpdateStrapi = useMemo(() => checkLatestStrapiVersion(strapiVersion, tag_name), [
tag_name,
]);
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions
const shouldShowNotDependentQueriesLoader =
(isFetching && isFetched) || status === 'loading' || fetchPermissionsStatus === 'loading';
const shouldShowLoader = isLoading || shouldShowNotDependentQueriesLoader;
if (shouldShowLoader) {
return <LoadingIndicatorPage />;
}
// TODO add error state
if (status === 'error') {
return <div>error...</div>;
}
return (
<AppInfosContext.Provider
value={{ ...appInfos, latestStrapiReleaseTag: tag_name, shouldUpdateStrapi }}
>
<RBACProvider permissions={permissions} refetchPermissions={refetch}>
<PluginsInitializer />
</RBACProvider>
</AppInfosContext.Provider>
);
};
export default AuthenticatedApp;

View File

@ -0,0 +1,99 @@
import React from 'react';
import { render } from '@testing-library/react';
import { QueryClientProvider, QueryClient } from 'react-query';
import { fetchAppInfo, fetchCurrentUserPermissions, fetchStrapiLatestRelease } from '../utils/api';
import packageJSON from '../../../../../package.json';
import AuthenticatedApp from '..';
const strapiVersion = packageJSON.version;
jest.mock('../utils/api', () => ({
fetchStrapiLatestRelease: jest.fn(),
fetchAppInfo: jest.fn(),
fetchCurrentUserPermissions: jest.fn(),
}));
jest.mock('../../PluginsInitializer', () => () => <div>PluginsInitializer</div>);
// eslint-disable-next-line react/prop-types
jest.mock('../../RBACProvider', () => ({ children }) => <div>{children}</div>);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
const app = (
<QueryClientProvider client={queryClient}>
<AuthenticatedApp />
</QueryClientProvider>
);
describe('Admin | components | AuthenticatedApp', () => {
beforeEach(() => {
jest.resetModules(); // Most important - it clears the cache
});
afterEach(() => {
jest.resetAllMocks();
});
it('should not crash', () => {
fetchStrapiLatestRelease.mockImplementation(() => Promise.resolve({ tag_name: strapiVersion }));
fetchAppInfo.mockImplementation(() =>
Promise.resolve({
autoReload: true,
communityEdition: false,
currentEnvironment: 'development',
nodeVersion: 'v14.13.1',
strapiVersion: '3.6.0',
})
);
fetchCurrentUserPermissions.mockImplementation(() => Promise.resolve([]));
const { container } = render(app);
expect(container.firstChild).toMatchInlineSnapshot(`
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: space-around;
-webkit-justify-content: space-around;
-ms-flex-pack: space-around;
justify-content: space-around;
width: 100%;
height: 100vh;
}
.c0 > div {
margin: auto;
width: 50px;
height: 50px;
border: 6px solid #f3f3f3;
border-top: 6px solid #1c91e7;
border-radius: 50%;
-webkit-animation: fEWCgj 2s linear infinite;
animation: fEWCgj 2s linear infinite;
}
<div
class="c0"
>
<div />
</div>
`);
});
it('should not fetch the latest release', () => {
render(app);
expect(fetchStrapiLatestRelease).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
import axios from 'axios';
import axiosInstance from '../../../utils/axiosInstance';
import packageJSON from '../../../../../package.json';
const strapiVersion = packageJSON.version;
const fetchStrapiLatestRelease = async () => {
try {
const {
data: { tag_name },
} = await axios.get('https://api.github.com/repos/strapi/strapi/releases/latest');
return tag_name;
} catch (err) {
// Don't throw an error
return strapiVersion;
}
};
const fetchAppInfo = async () => {
try {
const { data, headers } = await axiosInstance.get('/admin/information');
if (!headers['content-type'].includes('application/json')) {
throw new Error('Not found');
}
return data.data;
} catch (error) {
throw new Error(error);
}
};
const fetchCurrentUserPermissions = async () => {
try {
const { data, headers } = await axiosInstance.get('/admin/users/me/permissions');
if (!headers['content-type'].includes('application/json')) {
throw new Error('Not found');
}
return data.data;
} catch (err) {
throw new Error(err);
}
};
export { fetchAppInfo, fetchCurrentUserPermissions, fetchStrapiLatestRelease };

View File

@ -1,16 +0,0 @@
/*
*
* LanguageProvider actions
*
*/
/* eslint-disable */
import { CHANGE_LOCALE } from './constants';
export function changeLocale(languageLocale) {
return {
type: CHANGE_LOCALE,
locale: languageLocale,
};
}

View File

@ -1,8 +0,0 @@
/*
*
* LanguageProvider constants
*
*/
/* eslint-disable */
export const CHANGE_LOCALE = 'app/LanguageToggle/CHANGE_LOCALE';

View File

@ -1,14 +0,0 @@
import { useDispatch } from 'react-redux';
import { changeLocale } from '../actions';
const useChangeLanguage = () => {
const dispatch = useDispatch();
const changeLanguage = nextLocale => {
dispatch(changeLocale(nextLocale));
};
return changeLanguage;
};
export default useChangeLanguage;

View File

@ -6,44 +6,45 @@
* IntlProvider component and i18n messages (loaded from `app/translations`)
*/
import React from 'react';
import React, { useEffect, useReducer } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { IntlProvider } from 'react-intl';
import { defaultsDeep } from 'lodash';
import { selectLocale } from './selectors';
import defaultsDeep from 'lodash/defaultsDeep';
import LocalesProvider from '../LocalesProvider';
import localStorageKey from './utils/localStorageKey';
import init from './init';
import reducer, { initialState } from './reducer';
// eslint-disable-next-line react/prefer-stateless-function
export class LanguageProvider extends React.Component {
render() {
const messages = defaultsDeep(this.props.messages[this.props.locale], this.props.messages.en);
const LanguageProvider = ({ children, localeNames, messages }) => {
const [{ locale }, dispatch] = useReducer(reducer, initialState, () => init(localeNames));
return (
<IntlProvider
locale={this.props.locale}
defaultLocale="en"
messages={messages}
textComponent="span"
>
{React.Children.only(this.props.children)}
</IntlProvider>
);
}
}
useEffect(() => {
// Set user language in local storage.
window.localStorage.setItem(localStorageKey, locale);
}, [locale]);
const changeLocale = locale => {
dispatch({
type: 'CHANGE_LOCALE',
locale,
});
};
const appMessages = defaultsDeep(messages[locale], messages.en);
return (
<IntlProvider locale={locale} defaultLocale="en" messages={appMessages} textComponent="span">
<LocalesProvider changeLocale={changeLocale} localeNames={localeNames} messages={appMessages}>
{children}
</LocalesProvider>
</IntlProvider>
);
};
LanguageProvider.propTypes = {
children: PropTypes.element.isRequired,
locale: PropTypes.string.isRequired,
localeNames: PropTypes.objectOf(PropTypes.string).isRequired,
messages: PropTypes.object.isRequired,
};
const mapStateToProps = createSelector(selectLocale(), locale => ({ locale }));
function mapDispatchToProps(dispatch) {
return {
dispatch,
};
}
export default connect(mapStateToProps, mapDispatchToProps)(LanguageProvider);
export default LanguageProvider;

View File

@ -0,0 +1,13 @@
import localStorageKey from './utils/localStorageKey';
const init = localeNames => {
const languageFromLocaleStorage = window.localStorage.getItem(localStorageKey);
const appLanguage = localeNames[languageFromLocaleStorage] ? languageFromLocaleStorage : 'en';
return {
locale: appLanguage,
localeNames,
};
};
export default init;

View File

@ -4,47 +4,27 @@
*
*/
import { get, includes, split } from 'lodash';
// Import supported languages from the translations folder
import trads from '../../translations';
import { CHANGE_LOCALE } from './constants';
const languages = Object.keys(trads);
// Define a key to store and get user preferences in local storage.
const localStorageKey = 'strapi-admin-language';
// Detect user language.
const userLanguage =
window.localStorage.getItem(localStorageKey) ||
window.navigator.language ||
window.navigator.userLanguage;
let foundLanguage = includes(languages, userLanguage) && userLanguage;
if (!foundLanguage) {
// Split user language in a correct format.
const userLanguageShort = get(split(userLanguage, '-'), '0');
// Check that the language is included in the admin configuration.
foundLanguage = includes(languages, userLanguageShort) && userLanguageShort;
}
const initialState = {
locale: foundLanguage || 'en',
localeNames: { en: 'English' },
locale: 'en',
};
function languageProviderReducer(state = initialState, action) {
const languageProviderReducer = (state = initialState, action) => {
switch (action.type) {
case CHANGE_LOCALE:
// Set user language in local storage.
window.localStorage.setItem(localStorageKey, action.locale);
case 'CHANGE_LOCALE': {
const { locale } = action;
return { ...state, locale: action.locale };
default:
if (!state.localeNames[locale]) {
return state;
}
return { ...state, locale };
}
default: {
return state;
}
}
}
};
export default languageProviderReducer;
export { initialState };

View File

@ -1,17 +0,0 @@
import { createSelector } from 'reselect';
/**
* Direct selector to the languageToggle state domain
*/
const selectLanguage = () => state => state.language;
/**
* Select the language locale
*/
const selectLocale = () => createSelector(selectLanguage(), languageState => languageState.locale);
const makeSelectLocale = () => createSelector(selectLocale(), locale => ({ locale }));
export default makeSelectLocale;
export { selectLanguage, selectLocale };

View File

@ -0,0 +1,62 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useIntl } from 'react-intl';
import useLocalesProvider from '../../LocalesProvider/useLocalesProvider';
import LanguageProvider from '../index';
import en from '../../../translations/en.json';
import fr from '../../../translations/fr.json';
const messages = { en, fr };
const localeNames = { en: 'English', fr: 'Français' };
describe('LanguageProvider', () => {
afterEach(() => {
localStorage.removeItem('strapi-admin-language');
});
it('should not crash', () => {
const { container } = render(
<LanguageProvider messages={messages} localeNames={localeNames}>
<div>Test</div>
</LanguageProvider>
);
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
Test
</div>
`);
});
it('should change the locale and set the strapi-admin-language item in the localStorage', () => {
const Test = () => {
const { locale } = useIntl();
const { changeLocale } = useLocalesProvider();
return (
<div>
<h1>{localeNames[locale]}</h1>
<button type="button" onClick={() => changeLocale('fr')}>
CHANGE
</button>
</div>
);
};
render(
<LanguageProvider messages={messages} localeNames={localeNames}>
<Test />
</LanguageProvider>
);
expect(localStorage.getItem('strapi-admin-language')).toEqual('en');
expect(screen.getByText('English')).toBeInTheDocument();
userEvent.click(screen.getByText('CHANGE'));
expect(screen.getByText('Français')).toBeInTheDocument();
expect(localStorage.getItem('strapi-admin-language')).toEqual('fr');
});
});

View File

@ -0,0 +1,34 @@
import init from '../init';
const localeNames = { en: 'English', fr: 'Français' };
describe('LanguageProvider | init', () => {
afterEach(() => {
localStorage.removeItem('strapi-admin-language');
});
it('should return the language from the localStorage', () => {
localStorage.setItem('strapi-admin-language', 'fr');
expect(init(localeNames)).toEqual({
locale: 'fr',
localeNames,
});
});
it('should return "en" when the strapi-admin-language is not set in the locale storage', () => {
expect(init(localeNames)).toEqual({
locale: 'en',
localeNames,
});
});
it('should return "en" when the language from the local storage is not included in the localeNames', () => {
localStorage.setItem('strapi-admin-language', 'foo');
expect(init(localeNames)).toEqual({
locale: 'en',
localeNames,
});
});
});

View File

@ -0,0 +1,41 @@
import reducer, { initialState } from '../reducer';
describe('LanguageProvider | reducer', () => {
let state;
beforeEach(() => {
state = initialState;
});
it('should return the initialState', () => {
const action = { type: undefined };
expect(reducer(state, action)).toEqual(initialState);
});
it('should change the locale correctly when the locale is defined in the localeNames', () => {
state = {
localeNames: { en: 'English', fr: 'Français' },
locale: 'en',
};
const action = { type: 'CHANGE_LOCALE', locale: 'fr' };
const expected = {
localeNames: { en: 'English', fr: 'Français' },
locale: 'fr',
};
expect(reducer(state, action)).toEqual(expected);
});
it('should not change the locale when the language is not defined in the localeNames', () => {
state = {
localeNames: { en: 'English', fr: 'Français' },
locale: 'en',
};
const action = { type: 'CHANGE_LOCALE', locale: 'foo' };
expect(reducer(state, action)).toEqual(state);
});
});

View File

@ -0,0 +1,3 @@
const localStorageKey = 'strapi-admin-language';
export default localStorageKey;

View File

@ -1,24 +0,0 @@
import {
TOGGLE_IS_LOADING,
SET_CT_OR_ST_LINKS,
SET_SECTION_LINKS,
UNSET_IS_LOADING,
} from './constants';
export const setCtOrStLinks = (authorizedCtLinks, authorizedStLinks, contentTypeSchemas) => ({
type: SET_CT_OR_ST_LINKS,
data: { authorizedCtLinks, authorizedStLinks, contentTypeSchemas },
});
export const setSectionLinks = (authorizedGeneralLinks, authorizedPluginLinks) => ({
type: SET_SECTION_LINKS,
data: { authorizedGeneralLinks, authorizedPluginLinks },
});
export const toggleIsLoading = () => ({
type: TOGGLE_IS_LOADING,
});
export const unsetIsLoading = () => ({
type: UNSET_IS_LOADING,
});

View File

@ -5,11 +5,13 @@
*/
import React from 'react';
import { PropTypes } from 'prop-types';
import { useAppInfos } from '@strapi/helper-plugin';
import Wrapper, { A } from './Wrapper';
function LeftMenuFooter({ version }) {
function LeftMenuFooter() {
const projectType = process.env.STRAPI_ADMIN_PROJECT_TYPE;
const { strapiVersion } = useAppInfos();
return (
<Wrapper>
@ -19,12 +21,12 @@ function LeftMenuFooter({ version }) {
</A>
&nbsp;
<A
href={`https://github.com/strapi/strapi/releases/tag/v${version}`}
href={`https://github.com/strapi/strapi/releases/tag/v${strapiVersion}`}
key="github"
target="_blank"
rel="noopener noreferrer"
>
v{version}
v{strapiVersion}
</A>
&nbsp;
<A href="https://strapi.io" target="_blank" rel="noopener noreferrer">
@ -35,8 +37,4 @@ function LeftMenuFooter({ version }) {
);
}
LeftMenuFooter.propTypes = {
version: PropTypes.string.isRequired,
};
export default LeftMenuFooter;

View File

@ -1,97 +0,0 @@
/**
*
* LeftMenuLink
*
*/
import React from 'react';
import { startsWith } from 'lodash';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import styled from 'styled-components';
import { Link, withRouter } from 'react-router-dom';
import en from '../../../../translations/en.json';
import LeftMenuIcon from './LeftMenuIcon';
import A from './A';
import NotificationCount from './NotificationCount';
const LinkLabel = styled.span`
display: inline-block;
width: 100%;
padding-right: 1rem;
padding-left: 2.5rem;
`;
// TODO: refacto this file
const LeftMenuLinkContent = ({
destination,
iconName,
label,
location,
notificationsCount,
search,
}) => {
const isLinkActive = startsWith(
location.pathname.replace('/admin', '').concat('/'),
destination.concat('/')
);
// Check if messageId exists in en locale to prevent warning messages
const labelId = label.id || label;
const content =
en[labelId] || label.defaultMessage ? (
<FormattedMessage
id={labelId}
defaultMessage={label.defaultMessage || '{label}'}
values={{
label: `${label.id || label}`,
}}
>
{message => <LinkLabel>{message}</LinkLabel>}
</FormattedMessage>
) : (
<LinkLabel>{labelId}</LinkLabel>
);
// Create external or internal link.
return destination.includes('http') ? (
<A
className={isLinkActive ? 'linkActive' : ''}
href={destination}
target="_blank"
rel="noopener noreferrer"
>
<LeftMenuIcon icon={iconName} />
{content}
</A>
) : (
<A
as={Link}
className={isLinkActive ? 'linkActive' : ''}
to={{
pathname: destination,
search,
}}
>
<LeftMenuIcon icon={iconName} />
{content}
{notificationsCount > 0 && <NotificationCount count={notificationsCount} />}
</A>
);
};
LeftMenuLinkContent.defaultProps = {
search: null,
};
LeftMenuLinkContent.propTypes = {
destination: PropTypes.string.isRequired,
iconName: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
notificationsCount: PropTypes.number.isRequired,
search: PropTypes.string,
};
export default withRouter(LeftMenuLinkContent);

View File

@ -1,6 +1,7 @@
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
const A = styled.a`
const Link = styled(NavLink)`
display: flex;
position: relative;
padding-top: 1rem;
@ -30,10 +31,10 @@ const A = styled.a`
color: ${props => props.theme.main.colors.leftMenu['link-color']};
}
&.linkActive {
&.active {
color: white !important;
border-left: 0.3rem solid ${props => props.theme.main.colors.strapi.blue};
}
`;
export default A;
export default Link;

View File

@ -0,0 +1,10 @@
import styled from 'styled-components';
const LinkLabel = styled.span`
display: inline-block;
width: 100%;
padding-right: 1rem;
padding-left: 2.5rem;
`;
export default LinkLabel;

View File

@ -6,36 +6,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import LinkLabel from './LinkLabel';
import Link from './Link';
import LeftMenuIcon from './LeftMenuIcon';
import NotificationCount from './NotificationCount';
import LeftMenuLinkContent from './LeftMenuLinkContent';
const LeftMenuLink = ({ destination, iconName, label, location, notificationsCount, search }) => {
const LeftMenuLink = ({ to, icon, intlLabel, notificationsCount }) => {
return (
<LeftMenuLinkContent
destination={destination}
iconName={iconName}
label={label}
location={location}
notificationsCount={notificationsCount}
search={search}
/>
<Link to={to}>
<LeftMenuIcon icon={icon} />
{/* TODO change with new DS */}
<FormattedMessage {...intlLabel}>
{message => <LinkLabel>{message}</LinkLabel>}
</FormattedMessage>
{notificationsCount > 0 && <NotificationCount count={notificationsCount} />}
</Link>
);
};
LeftMenuLink.propTypes = {
destination: PropTypes.string.isRequired,
iconName: PropTypes.string,
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
notificationsCount: PropTypes.number.isRequired,
search: PropTypes.string,
to: PropTypes.string.isRequired,
icon: PropTypes.string,
intlLabel: PropTypes.object.isRequired,
notificationsCount: PropTypes.number,
};
LeftMenuLink.defaultProps = {
iconName: 'circle',
search: null,
icon: 'circle',
notificationsCount: 0,
};
export default LeftMenuLink;

View File

@ -1,11 +0,0 @@
import styled from 'styled-components';
const Search = styled.input`
width: 100%;
padding: 0 15px;
outline: 0;
font-size: 1.1rem;
color: ${({ theme }) => theme.main.colors.white};
`;
export default Search;

View File

@ -1,11 +0,0 @@
import styled from 'styled-components';
const SearchButton = styled.button`
padding: 0 10px;
line-height: normal;
&:focus {
outline: 0;
}
`;
export default SearchButton;

View File

@ -1,11 +0,0 @@
import styled from 'styled-components';
const SearchWrapper = styled.div`
display: flex;
width: 100%;
height: 19px;
justify-content: space-between;
border-bottom: 1px solid;
`;
export default SearchWrapper;

View File

@ -1,81 +0,0 @@
import React, { useState, createRef, useEffect } from 'react';
import { camelCase } from 'lodash';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
// TODO remove this
import messages from './messages.json';
import Search from './Search';
import Title from './Title';
import SearchButton from './SearchButton';
import SearchWrapper from './SearchWrapper';
const LeftMenuLinkHeader = ({ section, searchable, setSearch, search }) => {
const [showSearch, setShowSearch] = useState(false);
const ref = createRef();
const { id, defaultMessage } = messages[camelCase(section)];
useEffect(() => {
if (showSearch && ref.current) {
ref.current.focus();
}
}, [ref, showSearch]);
const toggleSearch = () => {
setShowSearch(prev => !prev);
};
const handleChange = ({ target: { value } }) => {
setSearch(value);
};
const clearSearch = () => {
setSearch('');
setShowSearch(false);
};
return (
<Title>
{!showSearch ? (
<>
<FormattedMessage id={id} defaultMessage={defaultMessage} />
{searchable && (
<SearchButton onClick={toggleSearch}>
<FontAwesomeIcon icon="search" />
</SearchButton>
)}
</>
) : (
<SearchWrapper>
<div>
<FontAwesomeIcon style={{ fontSize: 12 }} icon="search" />
</div>
<FormattedMessage id="components.Search.placeholder">
{message => (
<Search ref={ref} onChange={handleChange} value={search} placeholder={message} />
)}
</FormattedMessage>
<SearchButton onClick={clearSearch}>
<FontAwesomeIcon icon="times" />
</SearchButton>
</SearchWrapper>
)}
</Title>
);
};
LeftMenuLinkHeader.propTypes = {
section: PropTypes.string.isRequired,
searchable: PropTypes.bool,
setSearch: PropTypes.func,
search: PropTypes.string,
};
LeftMenuLinkHeader.defaultProps = {
search: null,
searchable: false,
setSearch: () => {},
};
export default LeftMenuLinkHeader;

View File

@ -1,38 +0,0 @@
{
"collectionType": {
"id": "app.components.LeftMenuLinkContainer.collectionTypes",
"defaultMessage": "Collection Types"
},
"singleType": {
"id": "app.components.LeftMenuLinkContainer.singleTypes",
"defaultMessage": "Single Types"
},
"listPlugins": {
"id": "app.components.LeftMenuLinkContainer.listPlugins",
"defaultMessage": "Plugins"
},
"installNewPlugin": {
"id": "app.components.LeftMenuLinkContainer.installNewPlugin",
"defaultMessage": "Marketplace"
},
"configuration": {
"id": "app.components.LeftMenuLinkContainer.configuration",
"defaultMessage": "Configurations"
},
"plugins": {
"id": "app.components.LeftMenuLinkContainer.plugins",
"defaultMessage": "Plugins"
},
"general": {
"id": "app.components.LeftMenuLinkContainer.general",
"defaultMessage": "General"
},
"noPluginsInstalled": {
"id": "app.components.LeftMenuLinkContainer.noPluginsInstalled",
"defaultMessage": "No plugins installed yet"
},
"settings": {
"id": "app.components.LeftMenuLinkContainer.settings",
"defaultMessage": "Settings"
}
}

View File

@ -1,21 +0,0 @@
import styled from 'styled-components';
const EmptyLinksList = styled.span`
min-height: 3.6rem;
padding: 0.6rem 1.6rem 2.6rem 0;
font-size: 1.4rem;
font-weight: 400;
color: ${({ theme }) => theme.main.colors.leftMenu['link-color']};
`;
EmptyLinksList.defaultProps = {
theme: {
main: {
colors: {
white: '#ffffff',
},
},
},
};
export default EmptyLinksList;

View File

@ -1,9 +0,0 @@
import styled from 'styled-components';
const EmptyLinksListWrapper = styled.div`
margin-bottom: 0.1rem;
padding: 0.8rem 0 0.7rem 2rem;
line-height: 18px;
`;
export default EmptyLinksListWrapper;

View File

@ -1,84 +1,22 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import matchSorter from 'match-sorter';
import { sortBy } from 'lodash';
import { FormattedMessage } from 'react-intl';
import LeftMenuLink from '../Link';
import LeftMenuLinkHeader from '../LinkHeader';
import LeftMenuListLink from './LeftMenuListLink';
import EmptyLinksList from './EmptyLinksList';
import EmptyLinksListWrapper from './EmptyLinksListWrapper';
const LeftMenuLinksSection = ({
section,
searchable,
location,
links,
emptyLinksListMessage,
shrink,
}) => {
const [search, setSearch] = useState('');
const filteredList = sortBy(
matchSorter(links, search, {
keys: ['label'],
}),
'label'
);
const LeftMenuLinksSection = ({ links }) => {
return (
<>
<LeftMenuLinkHeader
section={section}
searchable={searchable}
setSearch={setSearch}
search={search}
/>
<LeftMenuListLink shrink={shrink}>
{filteredList.length > 0 ? (
filteredList.map((link, index) => (
<LeftMenuLink
location={location}
// There is no id or unique value in the link object for the moment.
// eslint-disable-next-line react/no-array-index-key
key={index}
iconName={link.icon}
label={link.label}
destination={link.destination}
notificationsCount={link.notificationsCount || 0}
search={link.search}
/>
))
) : (
<EmptyLinksListWrapper>
<FormattedMessage id={emptyLinksListMessage} defaultMessage="No plugins installed yet">
{msg => <EmptyLinksList>{msg}</EmptyLinksList>}
</FormattedMessage>
</EmptyLinksListWrapper>
)}
<LeftMenuListLink>
{links.map(link => (
<LeftMenuLink {...link} key={link.to} />
))}
</LeftMenuListLink>
</>
);
};
LeftMenuLinksSection.defaultProps = {
shrink: false,
};
LeftMenuLinksSection.propTypes = {
section: PropTypes.string.isRequired,
searchable: PropTypes.bool.isRequired,
shrink: PropTypes.bool,
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
links: PropTypes.arrayOf(PropTypes.object).isRequired,
emptyLinksListMessage: PropTypes.string,
};
LeftMenuLinksSection.defaultProps = {
emptyLinksListMessage: 'components.ListRow.empty',
};
export default LeftMenuLinksSection;

View File

@ -1,13 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1140;
background: ${({ theme }) => theme.main.colors.white};
`;
export default Wrapper;

View File

@ -1,34 +0,0 @@
/*
*
* This component is used to show a global loader while permissions are being checked
* it prevents from lifting the state up in order to avoid setting more logic into the Admin container
* this way we can show a global loader without modifying the Admin code
*
*/
import React from 'react';
import { createPortal } from 'react-dom';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import Wrapper from './Wrapper';
const MOUNT_NODE = document.getElementById('app') || document.createElement('div');
const Loader = ({ show }) => {
if (show) {
return createPortal(
<Wrapper>
<LoadingIndicatorPage />
</Wrapper>,
MOUNT_NODE
);
}
return null;
};
Loader.propTypes = {
show: PropTypes.bool.isRequired,
};
export default Loader;

View File

@ -1,6 +1,6 @@
import styled from 'styled-components';
const Title = styled.div`
const SectionTitle = styled.div`
display: flex;
justify-content: space-between;
padding-left: 2rem;
@ -15,7 +15,7 @@ const Title = styled.div`
max-height: 26px;
`;
Title.defaultProps = {
SectionTitle.defaultProps = {
theme: {
main: {
colors: {
@ -27,4 +27,4 @@ Title.defaultProps = {
},
};
export default Title;
export default SectionTitle;

View File

@ -2,4 +2,4 @@ export { default as Footer } from './Footer';
export { default as Header } from './Header';
export { default as LinksContainer } from './Links';
export { default as LinksSection } from './LinksSection';
export { default as Loader } from './Loader';
export { default as SectionTitle } from './SectionTitle';

View File

@ -1,4 +0,0 @@
export const SET_CT_OR_ST_LINKS = 'StrapiAdmin/LeftMenu/SET_CT_OR_ST_LINKS';
export const SET_SECTION_LINKS = 'StrapiAdmin/LeftMenu/SET_SECTION_LINKS';
export const TOGGLE_IS_LOADING = 'StrapiAdmin/LeftMenu/TOGGLE_IS_LOADING';
export const UNSET_IS_LOADING = 'StrapiAdmin/LeftMenu/UNSET_IS_LOADING';

View File

@ -1,96 +1,49 @@
import React, { memo, useEffect } from 'react';
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
// import { LinksSection, LinksContainer } from '../LeftMenuCompos';
import { Footer, Header, Loader, LinksContainer, LinksSection } from './compos';
import { FormattedMessage } from 'react-intl';
import { Footer, Header, LinksContainer, LinksSection, SectionTitle } from './compos';
import Wrapper from './Wrapper';
import useMenuSections from './useMenuSections';
const LeftMenu = ({ shouldUpdateStrapi, version, plugins, setUpdateMenu }) => {
const location = useLocation();
const {
state: {
isLoading,
collectionTypesSectionLinks,
singleTypesSectionLinks,
generalSectionLinks,
pluginsSectionLinks,
},
toggleLoading,
generateMenu,
} = useMenuSections(plugins, shouldUpdateStrapi);
const filteredCollectionTypeLinks = collectionTypesSectionLinks.filter(
({ isDisplayed }) => isDisplayed
);
const filteredSingleTypeLinks = singleTypesSectionLinks.filter(({ isDisplayed }) => isDisplayed);
// This effect is really temporary until we create the menu api
// We need this because we need to regenerate the links when the settings are being changed
// in the content manager configurations list
useEffect(() => {
setUpdateMenu(() => {
toggleLoading();
generateMenu();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }) => {
return (
<Wrapper>
<Loader show={isLoading} />
<Header />
<LinksContainer>
{filteredCollectionTypeLinks.length > 0 && (
<LinksSection
section="collectionType"
name="collectionType"
links={filteredCollectionTypeLinks}
location={location}
searchable
/>
)}
{filteredSingleTypeLinks.length > 0 && (
<LinksSection
section="singleType"
name="singleType"
links={filteredSingleTypeLinks}
location={location}
searchable
/>
)}
{pluginsSectionLinks.length > 0 && (
<LinksSection
section="plugins"
name="plugins"
links={pluginsSectionLinks}
location={location}
searchable={false}
emptyLinksListMessage="app.components.LeftMenuLinkContainer.noPluginsInstalled"
/>
<>
<SectionTitle>
<FormattedMessage
id="app.components.LeftMenuLinkContainer.listPlugins"
defaultMessage="Plugins"
/>
</SectionTitle>
<LinksSection
links={pluginsSectionLinks}
searchable={false}
emptyLinksListMessage="app.components.LeftMenuLinkContainer.noPluginsInstalled"
/>
</>
)}
{generalSectionLinks.length > 0 && (
<LinksSection
section="general"
name="general"
links={generalSectionLinks}
location={location}
searchable={false}
/>
<>
<SectionTitle>
<FormattedMessage
id="app.components.LeftMenuLinkContainer.general"
defaultMessage="General"
/>
</SectionTitle>
<LinksSection links={generalSectionLinks} searchable={false} />
</>
)}
</LinksContainer>
<Footer key="footer" version={version} />
<Footer key="footer" />
</Wrapper>
);
};
LeftMenu.propTypes = {
shouldUpdateStrapi: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
plugins: PropTypes.object.isRequired,
setUpdateMenu: PropTypes.func.isRequired,
generalSectionLinks: PropTypes.array.isRequired,
pluginsSectionLinks: PropTypes.array.isRequired,
};
export default memo(LeftMenu);

View File

@ -1,78 +0,0 @@
/* eslint-disable consistent-return */
import produce from 'immer';
import adminPermissions from '../../permissions';
import {
SET_CT_OR_ST_LINKS,
SET_SECTION_LINKS,
TOGGLE_IS_LOADING,
UNSET_IS_LOADING,
} from './constants';
const initialState = {
collectionTypesSectionLinks: [],
generalSectionLinks: [
{
icon: 'list',
label: 'app.components.LeftMenuLinkContainer.listPlugins',
destination: '/list-plugins',
isDisplayed: false,
permissions: adminPermissions.marketplace.main,
notificationsCount: 0,
},
{
icon: 'shopping-basket',
label: 'app.components.LeftMenuLinkContainer.installNewPlugin',
destination: '/marketplace',
isDisplayed: false,
permissions: adminPermissions.marketplace.main,
notificationsCount: 0,
},
{
icon: 'cog',
label: 'app.components.LeftMenuLinkContainer.settings',
isDisplayed: false,
destination: '/settings',
// Permissions of this link are retrieved in the init phase
// using the settings menu
permissions: [],
notificationsCount: 0,
},
],
singleTypesSectionLinks: [],
pluginsSectionLinks: [],
isLoading: true,
};
const reducer = (state = initialState, action) =>
produce(state, draftState => {
switch (action.type) {
case SET_CT_OR_ST_LINKS: {
const { authorizedCtLinks, authorizedStLinks } = action.data;
draftState.collectionTypesSectionLinks = authorizedCtLinks;
draftState.singleTypesSectionLinks = authorizedStLinks;
break;
}
case SET_SECTION_LINKS: {
const { authorizedGeneralLinks, authorizedPluginLinks } = action.data;
draftState.generalSectionLinks = authorizedGeneralLinks;
draftState.pluginsSectionLinks = authorizedPluginLinks;
break;
}
case TOGGLE_IS_LOADING: {
draftState.isLoading = !state.isLoading;
break;
}
case UNSET_IS_LOADING: {
draftState.isLoading = false;
break;
}
default:
return draftState;
}
});
export default reducer;
export { initialState };

View File

@ -1,3 +0,0 @@
const selectMenuLinks = state => state.menu;
export default selectMenuLinks;

View File

@ -1,77 +0,0 @@
import reducer, { initialState } from '../reducer';
import { SET_CT_OR_ST_LINKS, SET_SECTION_LINKS, TOGGLE_IS_LOADING } from '../constants';
describe('ADMIN | LeftMenu | reducer', () => {
describe('DEFAULT_ACTION', () => {
it('should return the initialState', () => {
const state = {
test: true,
};
expect(reducer(state, {})).toEqual(state);
});
});
describe('TOGGLE_IS_LOADING', () => {
it('should change the isLoading property correctly', () => {
const state = {
isLoading: true,
};
const expected = {
isLoading: false,
};
const action = {
type: TOGGLE_IS_LOADING,
};
expect(reducer(state, action)).toEqual(expected);
});
});
describe('SET_SECTION_LINKS', () => {
it('sets the generalSectionLinks and the pluginsSectionLinks with the action', () => {
const state = { ...initialState };
const action = {
type: SET_SECTION_LINKS,
data: {
authorizedGeneralLinks: ['authorizd', 'links'],
authorizedPluginLinks: ['authorizd', 'plugin-links'],
},
};
const expected = {
...initialState,
generalSectionLinks: ['authorizd', 'links'],
pluginsSectionLinks: ['authorizd', 'plugin-links'],
};
const actual = reducer(state, action);
expect(actual).toEqual(expected);
});
});
describe('SET_CT_OR_ST_LINKS', () => {
it('sets the generalSectionLinks and the pluginsSectionLinks with the action', () => {
const state = { ...initialState };
const action = {
type: SET_CT_OR_ST_LINKS,
data: {
authorizedCtLinks: ['authorizd', 'ct-links'],
authorizedStLinks: ['authorizd', 'st-links'],
},
};
const expected = {
...initialState,
collectionTypesSectionLinks: ['authorizd', 'ct-links'],
singleTypesSectionLinks: ['authorizd', 'st-links'],
};
const actual = reducer(state, action);
expect(actual).toEqual(expected);
});
});
});

View File

@ -1,65 +0,0 @@
import { useEffect, useRef } from 'react';
import { useUser, useNotification } from '@strapi/helper-plugin';
import { useSelector, useDispatch } from 'react-redux';
import getCtOrStLinks from './utils/getCtOrStLinks';
import getPluginSectionLinks from './utils/getPluginSectionLinks';
import getGeneralLinks from './utils/getGeneralLinks';
import { setCtOrStLinks, setSectionLinks, toggleIsLoading, unsetIsLoading } from './actions';
import useSettingsMenu from '../../hooks/useSettingsMenu';
import toPluginLinks from './utils/toPluginLinks';
import selectMenuLinks from './selectors';
const useMenuSections = (plugins, shouldUpdateStrapi) => {
const toggleNotification = useNotification();
const state = useSelector(selectMenuLinks);
const dispatch = useDispatch();
const { userPermissions } = useUser();
const { menu: settingsMenu } = useSettingsMenu(true);
// We are using a ref because we don't want our effect to have this in its dependencies array
const generalSectionLinksRef = useRef(state.generalSectionLinks);
const shouldUpdateStrapiRef = useRef(shouldUpdateStrapi);
// Since the settingsMenu is not managing any state because of the true argument we can use a ref here
// so we don't need to add it to the effect dependencies array
const settingsMenuRef = useRef(settingsMenu);
// Once in the app lifecycle the plugins should not be added into any dependencies array, in order to prevent
// the effect to be run when another plugin is using one plugins internal api for instance
// so it's definitely ok to use a ref here
const pluginsRef = useRef(plugins);
const toggleLoading = () => dispatch(toggleIsLoading());
const resolvePermissions = async (permissions = userPermissions) => {
const pluginsSectionLinks = toPluginLinks(pluginsRef.current);
const { authorizedCtLinks, authorizedStLinks, contentTypes } = await getCtOrStLinks(
permissions,
toggleNotification
);
const authorizedPluginSectionLinks = await getPluginSectionLinks(
permissions,
pluginsSectionLinks
);
const authorizedGeneralSectionLinks = await getGeneralLinks(
permissions,
generalSectionLinksRef.current,
settingsMenuRef.current,
shouldUpdateStrapiRef.current
);
dispatch(setCtOrStLinks(authorizedCtLinks, authorizedStLinks, contentTypes));
dispatch(setSectionLinks(authorizedGeneralSectionLinks, authorizedPluginSectionLinks));
dispatch(unsetIsLoading());
};
const resolvePermissionsRef = useRef(resolvePermissions);
useEffect(() => {
resolvePermissionsRef.current(userPermissions);
}, [userPermissions, dispatch]);
return { state, generateMenu: resolvePermissionsRef.current, toggleLoading };
};
export default useMenuSections;

View File

@ -1,20 +0,0 @@
import { get, isEmpty } from 'lodash';
const getSettingsMenuLinksPermissions = menu =>
menu.reduce((acc, current) => {
const links = get(current, 'links', []);
const permissions = links.reduce((acc, current) => {
let currentPermissions = get(current, 'permissions', null);
if (isEmpty(currentPermissions)) {
return [...acc, null];
}
return [...acc, ...currentPermissions];
}, []);
return [...acc, ...permissions];
}, []);
export default getSettingsMenuLinksPermissions;

View File

@ -1,2 +0,0 @@
export { default as generateModelsLinks } from './generateModelsLinks';
export { default as getSettingsMenuLinksPermissions } from './getSettingsMenuLinksPermissions';

View File

@ -1,549 +0,0 @@
import { hasPermissions } from '@strapi/helper-plugin';
import getGeneralLinks from '../getGeneralLinks';
jest.mock('@strapi/helper-plugin');
describe('getGeneralLinks', () => {
it('resolves valid general links from real data', async () => {
hasPermissions.mockImplementation(() => Promise.resolve(true));
const permissions = [
{
id: 458,
action: 'plugins::i18n.locale.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 459,
action: 'plugins::content-manager.explorer.create',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['en'],
},
conditions: [],
},
{
id: 460,
action: 'plugins::content-manager.explorer.read',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['en'],
},
conditions: [],
},
{
id: 461,
action: 'plugins::content-manager.explorer.read',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['fr-FR'],
},
conditions: [],
},
{
id: 462,
action: 'plugins::content-manager.explorer.update',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['fr-FR'],
},
conditions: [],
},
];
const generalSectionRawLinks = [
{
icon: 'list',
label: 'app.components.LeftMenuLinkContainer.listPlugins',
destination: '/list-plugins',
isDisplayed: false,
permissions: [
{
action: 'admin::marketplace.read',
subject: null,
},
{
action: 'admin::marketplace.plugins.install',
subject: null,
},
{
action: 'admin::marketplace.plugins.uninstall',
subject: null,
},
],
notificationsCount: 0,
},
{
icon: 'shopping-basket',
label: 'app.components.LeftMenuLinkContainer.installNewPlugin',
destination: '/marketplace',
isDisplayed: false,
permissions: [
{
action: 'admin::marketplace.read',
subject: null,
},
{
action: 'admin::marketplace.plugins.install',
subject: null,
},
{
action: 'admin::marketplace.plugins.uninstall',
subject: null,
},
],
notificationsCount: 0,
},
{
icon: 'cog',
label: 'app.components.LeftMenuLinkContainer.settings',
isDisplayed: false,
destination: '/settings',
permissions: [],
notificationsCount: 0,
},
];
const settingsMenu = [
{
id: 'global',
title: {
id: 'Settings.global',
},
links: [
{
title: {
id: 'i18n.plugin.name',
defaultMessage: 'Internationalization',
},
name: 'internationalization',
to: '/settings/internationalization',
permissions: [
{
action: 'plugins::i18n.locale.read',
subject: null,
},
{
action: 'plugins::i18n.locale.create',
subject: null,
},
],
isDisplayed: false,
},
{
title: {
id: 'upload.plugin.name',
defaultMessage: 'Media Library',
},
name: 'media-library',
to: '/settings/media-library',
permissions: [
{
action: 'plugins::upload.settings.read',
subject: null,
},
],
isDisplayed: false,
},
{
title: {
id: 'Settings.sso.title',
},
to: '/settings/single-sign-on',
name: 'sso',
isDisplayed: false,
permissions: [
{
action: 'admin::provider-login.read',
subject: null,
},
],
},
{
title: {
id: 'Settings.webhooks.title',
},
to: '/settings/webhooks',
name: 'webhooks',
isDisplayed: false,
permissions: [
{
action: 'admin::webhooks.create',
subject: null,
},
{
action: 'admin::webhooks.read',
subject: null,
},
{
action: 'admin::webhooks.update',
subject: null,
},
{
action: 'admin::webhooks.delete',
subject: null,
},
],
},
],
},
{
id: 'permissions',
title: 'Settings.permissions',
links: [
{
title: {
id: 'Settings.permissions.menu.link.roles.label',
},
to: '/settings/roles',
name: 'roles',
isDisplayed: false,
permissions: [
{
action: 'admin::roles.create',
subject: null,
},
{
action: 'admin::roles.update',
subject: null,
},
{
action: 'admin::roles.read',
subject: null,
},
{
action: 'admin::roles.delete',
subject: null,
},
],
},
{
title: {
id: 'Settings.permissions.menu.link.users.label',
},
to: '/settings/users?pageSize=10&page=1&_sort=firstname%3AASC',
name: 'users',
isDisplayed: false,
permissions: [
{
action: 'admin::users.create',
subject: null,
},
{
action: 'admin::users.read',
subject: null,
},
{
action: 'admin::users.update',
subject: null,
},
{
action: 'admin::users.delete',
subject: null,
},
],
},
],
},
];
const expected = [
{
icon: 'list',
label: 'app.components.LeftMenuLinkContainer.listPlugins',
destination: '/list-plugins',
isDisplayed: false,
permissions: [
{
action: 'admin::marketplace.read',
subject: null,
},
{
action: 'admin::marketplace.plugins.install',
subject: null,
},
{
action: 'admin::marketplace.plugins.uninstall',
subject: null,
},
],
notificationsCount: 0,
},
{
icon: 'shopping-basket',
label: 'app.components.LeftMenuLinkContainer.installNewPlugin',
destination: '/marketplace',
isDisplayed: false,
permissions: [
{
action: 'admin::marketplace.read',
subject: null,
},
{
action: 'admin::marketplace.plugins.install',
subject: null,
},
{
action: 'admin::marketplace.plugins.uninstall',
subject: null,
},
],
notificationsCount: 0,
},
{
icon: 'cog',
label: 'app.components.LeftMenuLinkContainer.settings',
isDisplayed: false,
destination: '/settings',
permissions: [
{
action: 'plugins::i18n.locale.read',
subject: null,
},
{
action: 'plugins::i18n.locale.create',
subject: null,
},
{
action: 'plugins::upload.settings.read',
subject: null,
},
{
action: 'admin::provider-login.read',
subject: null,
},
{
action: 'admin::webhooks.create',
subject: null,
},
{
action: 'admin::webhooks.read',
subject: null,
},
{
action: 'admin::webhooks.update',
subject: null,
},
{
action: 'admin::webhooks.delete',
subject: null,
},
{
action: 'admin::roles.create',
subject: null,
},
{
action: 'admin::roles.update',
subject: null,
},
{
action: 'admin::roles.read',
subject: null,
},
{
action: 'admin::roles.delete',
subject: null,
},
{
action: 'admin::users.create',
subject: null,
},
{
action: 'admin::users.read',
subject: null,
},
{
action: 'admin::users.update',
subject: null,
},
{
action: 'admin::users.delete',
subject: null,
},
],
notificationsCount: 0,
notificationCount: 0,
},
];
const actual = await getGeneralLinks(permissions, generalSectionRawLinks, settingsMenu, false);
expect(actual).toEqual(expected);
});
it('resolves an empty array when the destination (/settings) is not in the authorized links', async () => {
hasPermissions.mockImplementation(() => Promise.resolve(false));
const permissions = [
{
id: 458,
action: 'plugins::i18n.locale.read',
subject: null,
properties: {},
conditions: [],
},
{
id: 459,
action: 'plugins::content-manager.explorer.create',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['en'],
},
conditions: [],
},
{
id: 460,
action: 'plugins::content-manager.explorer.read',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['en'],
},
conditions: [],
},
{
id: 461,
action: 'plugins::content-manager.explorer.read',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['fr-FR'],
},
conditions: [],
},
{
id: 462,
action: 'plugins::content-manager.explorer.update',
subject: 'application::article.article',
properties: {
fields: ['Name'],
locales: ['fr-FR'],
},
conditions: [],
},
];
const generalSectionRawLinks = [
{
icon: 'list',
label: 'app.components.LeftMenuLinkContainer.listPlugins',
destination: '/list-plugins',
isDisplayed: false,
permissions: [],
notificationsCount: 0,
},
{
icon: 'shopping-basket',
label: 'app.components.LeftMenuLinkContainer.installNewPlugin',
destination: '/marketplace',
isDisplayed: false,
permissions: [],
notificationsCount: 0,
},
{
icon: 'cog',
label: 'app.components.LeftMenuLinkContainer.settings',
isDisplayed: false,
destination: '/settings',
permissions: [],
notificationsCount: 0,
},
];
const settingsMenu = [
{
id: 'global',
title: {
id: 'Settings.global',
},
links: [
{
title: {
id: 'i18n.plugin.name',
defaultMessage: 'Internationalization',
},
name: 'internationalization',
to: '/settings/internationalization',
permissions: [
{
action: 'plugins::i18n.locale.read',
subject: null,
},
{
action: 'plugins::i18n.locale.create',
subject: null,
},
],
isDisplayed: false,
},
{
title: {
id: 'upload.plugin.name',
defaultMessage: 'Media Library',
},
name: 'media-library',
to: '/settings/media-library',
permissions: [
{
action: 'plugins::upload.settings.read',
subject: null,
},
],
isDisplayed: false,
},
{
title: {
id: 'Settings.sso.title',
},
to: '/settings/single-sign-on',
name: 'sso',
isDisplayed: false,
permissions: [],
},
{
title: {
id: 'Settings.webhooks.title',
},
to: '/settings/webhooks',
name: 'webhooks',
isDisplayed: false,
permissions: [],
},
],
},
{
id: 'permissions',
title: 'Settings.permissions',
links: [
{
title: {
id: 'Settings.permissions.menu.link.roles.label',
},
to: '/settings/roles',
name: 'roles',
isDisplayed: false,
permissions: [],
},
{
title: {
id: 'Settings.permissions.menu.link.users.label',
},
to: '/settings/users?pageSize=10&page=1&_sort=firstname%3AASC',
name: 'users',
isDisplayed: false,
permissions: [],
},
],
},
];
const expected = [];
const actual = await getGeneralLinks(permissions, generalSectionRawLinks, settingsMenu, false);
expect(actual).toEqual(expected);
});
});

View File

@ -1,86 +0,0 @@
import getSettingsMenuLinksPermissions from '../getSettingsMenuLinksPermissions';
describe('ADMIN | LeftMenu | utils | getSettingsMenuLinksPermissions', () => {
it('should return an array containing all the permissions of each link', () => {
const data = [
{
id: 'global',
title: { id: 'Settings.global' },
links: [
{
title: 'Settings.webhooks.title',
to: '/settings/webhooks',
name: 'webhooks',
permissions: [
{ action: 'admin::webhook.create', subject: null },
{ action: 'admin::webhook.read', subject: null },
{ action: 'admin::webhook.update', subject: null },
{ action: 'admin::webhook.delete', subject: null },
],
},
{
name: 'media-library',
permissions: [
{
action: 'plugins::upload.settings.read',
subject: null,
},
],
title: { id: 'upload.plugin.name', defaultMessage: 'Media Library' },
to: '/settings/media-library',
},
],
},
{
id: 'permissions',
title: 'Settings.permissions',
links: [
{
title: 'roles',
to: '/settings/roles',
name: 'roles',
permissions: [
{ action: 'admin::roles.create', subject: null },
{ action: 'admin::roles.update', subject: null },
{ action: 'admin::roles.read', subject: null },
{ action: 'admin::roles.delete', subject: null },
],
},
{
title: 'users',
to: '/settings/users?pageSize=10&page=1&_sort=firstname%3AASC',
name: 'users',
permissions: [
{ action: 'admin::users.create', subject: null },
{ action: 'admin::users.read', subject: null },
{ action: 'admin::users.update', subject: null },
{ action: 'admin::users.delete', subject: null },
],
},
],
},
];
const expected = [
{ action: 'admin::webhook.create', subject: null },
{ action: 'admin::webhook.read', subject: null },
{ action: 'admin::webhook.update', subject: null },
{ action: 'admin::webhook.delete', subject: null },
{
action: 'plugins::upload.settings.read',
subject: null,
},
{ action: 'admin::roles.create', subject: null },
{ action: 'admin::roles.update', subject: null },
{ action: 'admin::roles.read', subject: null },
{ action: 'admin::roles.delete', subject: null },
{ action: 'admin::users.create', subject: null },
{ action: 'admin::users.read', subject: null },
{ action: 'admin::users.update', subject: null },
{ action: 'admin::users.delete', subject: null },
];
expect(getSettingsMenuLinksPermissions(data)).toEqual(expected);
});
});

View File

@ -1,57 +0,0 @@
import toPluginLinks from '../toPluginLinks';
describe('toPluginLinks', () => {
it('transforms a plugin object into an array of plugin page links', async () => {
const plugins = [
{
id: 'content-type-builder',
description: 'content-type-builder.plugin.description',
name: 'Content Type Builder',
menu: {
pluginsSectionLinks: [
{
destination: '/plugins/content-type-builder',
icon: 'paint-brush',
label: {
id: 'content-type-builder.plugin.name',
defaultMessage: 'Content-Types Builder',
},
name: 'Content Type Builder',
permissions: [
{
action: 'plugins::content-type-builder.read',
subject: null,
},
],
},
],
},
},
{
id: 'content-manager',
description: 'content-manager.plugin.description',
name: 'Content Manager',
},
];
const expected = [
{
destination: '/plugins/content-type-builder',
icon: 'paint-brush',
label: {
id: 'content-type-builder.plugin.name',
defaultMessage: 'Content-Types Builder',
},
permissions: [
{
action: 'plugins::content-type-builder.read',
subject: null,
},
],
},
];
const actual = toPluginLinks(plugins);
expect(actual).toEqual(expected);
});
});

View File

@ -1,19 +0,0 @@
import get from 'lodash/get';
import omit from 'lodash/omit';
import sortLinks from '../../../utils/sortLinks';
const toPluginLinks = plugins => {
const pluginsLinks = Object.values(plugins).reduce((acc, current) => {
const pluginsSectionLinks = get(current, 'menu.pluginsSectionLinks', []);
return [...acc, ...pluginsSectionLinks];
}, []);
const sortedLinks = sortLinks(pluginsLinks).map(link => {
return { ...omit(link, 'name') };
});
return sortedLinks;
};
export default toPluginLinks;

View File

@ -4,76 +4,42 @@
*
*/
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import { bindActionCreators, compose } from 'redux';
import cn from 'classnames';
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import translationMessages, { languageNativeNames } from '../../translations';
import makeSelectLocale from '../LanguageProvider/selectors';
import { changeLocale } from '../LanguageProvider/actions';
import useLocalesProvider from '../LocalesProvider/useLocalesProvider';
import Wrapper from './Wrapper';
// TODO
const languages = Object.keys(translationMessages);
export class LocaleToggle extends React.Component {
// eslint-disable-line
state = { isOpen: false };
const LocaleToggle = () => {
const { changeLocale, localeNames } = useLocalesProvider();
toggle = () => this.setState(prevState => ({ isOpen: !prevState.isOpen }));
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
const { locale } = useIntl();
render() {
const {
currentLocale: { locale },
} = this.props;
return (
<Wrapper>
<ButtonDropdown isOpen={isOpen} toggle={toggle}>
<DropdownToggle className="localeDropdownContent">
<span>{localeNames[locale]}</span>
</DropdownToggle>
return (
<Wrapper>
<ButtonDropdown isOpen={this.state.isOpen} toggle={this.toggle}>
<DropdownToggle className="localeDropdownContent">
<span>{languageNativeNames[locale]}</span>
</DropdownToggle>
<DropdownMenu className="localeDropdownMenu">
{languages.map(language => (
<DropdownMenu className="localeDropdownMenu">
{Object.keys(localeNames).map(lang => {
return (
<DropdownItem
key={language}
onClick={() => this.props.changeLocale(language)}
className={cn(
'localeToggleItem',
locale === language ? 'localeToggleItemActive' : ''
)}
key={lang}
onClick={() => changeLocale(lang)}
className={`localeToggleItem ${locale === lang ? 'localeToggleItemActive' : ''}`}
>
{languageNativeNames[language]}
{localeNames[lang]}
</DropdownItem>
))}
</DropdownMenu>
</ButtonDropdown>
</Wrapper>
);
}
}
LocaleToggle.propTypes = {
changeLocale: PropTypes.func.isRequired,
currentLocale: PropTypes.object.isRequired,
);
})}
</DropdownMenu>
</ButtonDropdown>
</Wrapper>
);
};
const mapStateToProps = createStructuredSelector({
currentLocale: makeSelectLocale(),
});
export function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
changeLocale,
},
dispatch
);
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
export default compose(withConnect)(LocaleToggle);
export default LocaleToggle;

View File

@ -1,61 +1,217 @@
import React from 'react';
import { shallow } from 'enzyme';
import { DropdownItem } from 'reactstrap';
import { changeLocale } from '../../LanguageProvider/actions';
import { LocaleToggle, mapDispatchToProps } from '../index';
import { render } from '@testing-library/react';
import LanguageProvider from '../../LanguageProvider';
import en from '../../../translations/en.json';
import LocaleToggle from '../index';
const messages = { en };
const localeNames = { en: 'English' };
describe('<LocaleToggle />', () => {
let props;
beforeEach(() => {
props = {
changeLocale: jest.fn(),
currentLocale: {
locale: 'en',
},
};
});
it('should not crash', () => {
shallow(<LocaleToggle {...props} />);
});
const App = (
<LanguageProvider messages={messages} localeNames={localeNames}>
<LocaleToggle />
</LanguageProvider>
);
describe('<LocaleToggle />, toggle instance', () => {
it('should update the state when called', () => {
const renderedComponent = shallow(<LocaleToggle {...props} />);
const { toggle } = renderedComponent.instance();
const { container } = render(App);
expect(container.firstChild).toMatchInlineSnapshot(`
.c0 {
-webkit-font-smoothing: antialiased;
}
toggle();
.c0 > div {
height: 6rem;
line-height: 5.8rem;
z-index: 999;
}
expect(renderedComponent.state('isOpen')).toBe(true);
});
.c0 > div > button {
width: 100%;
padding: 0 30px;
background: transparent;
border: none;
border-radius: 0;
color: #333740;
font-weight: 500;
text-align: right;
cursor: pointer;
-webkit-transition: background 0.2s ease-out;
transition: background 0.2s ease-out;
}
it('call the toggle handle on click', () => {
const renderedComponent = shallow(<LocaleToggle {...props} />);
renderedComponent.setState({ isOpen: true });
const dropDown = renderedComponent.find(DropdownItem).at(0);
dropDown.simulate('click');
.c0 > div > button:hover,
.c0 > div > button:focus,
.c0 > div > button:active {
color: #333740;
background-color: #fafafb !important;
}
expect(props.changeLocale).toHaveBeenCalled();
});
});
.c0 > div > button > i,
.c0 > div > button > svg {
margin-left: 10px;
-webkit-transition: -webkit-transform 0.3s ease-out;
-webkit-transition: transform 0.3s ease-out;
transition: transform 0.3s ease-out;
}
describe('<LocaleToggle />, mapDispatchToProps', () => {
describe('changeLocale', () => {
it('should be injected', () => {
const dispatch = jest.fn();
const result = mapDispatchToProps(dispatch);
.c0 > div > button > i[alt='true'],
.c0 > div > button > svg[alt='true'] {
-webkit-transform: rotateX(180deg);
-ms-transform: rotateX(180deg);
transform: rotateX(180deg);
}
expect(result.changeLocale).toBeDefined();
});
.c0 .localeDropdownContent {
-webkit-font-smoothing: antialiased;
}
it('should dispatch the changeLocale action when called', () => {
const dispatch = jest.fn();
const result = mapDispatchToProps(dispatch);
result.changeLocale();
.c0 .localeDropdownContent span {
color: #333740;
font-size: 13px;
font-family: Lato;
font-weight: 500;
-webkit-letter-spacing: 0.5;
-moz-letter-spacing: 0.5;
-ms-letter-spacing: 0.5;
letter-spacing: 0.5;
vertical-align: baseline;
}
expect(dispatch).toHaveBeenCalledWith(changeLocale());
});
});
.c0 .localeDropdownMenu {
min-width: 90px !important;
max-height: 162px !important;
overflow: auto !important;
margin: 0 !important;
padding: 0;
line-height: 1.8rem;
border: none !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
box-shadow: 0 1px 4px 0px rgba(40,42,49,0.05);
}
.c0 .localeDropdownMenu:before {
content: '';
position: absolute;
top: -3px;
left: -1px;
width: calc(100% + 1px);
height: 3px;
box-shadow: 0 1px 2px 0 rgba(40,42,49,0.16);
}
.c0 .localeDropdownMenu > button {
height: 40px;
padding: 0px 15px;
line-height: 40px;
color: #f75b1d;
font-size: 13px;
font-weight: 500;
-webkit-letter-spacing: 0.5;
-moz-letter-spacing: 0.5;
-ms-letter-spacing: 0.5;
letter-spacing: 0.5;
}
.c0 .localeDropdownMenu > button:hover,
.c0 .localeDropdownMenu > button:focus,
.c0 .localeDropdownMenu > button:active {
background-color: #fafafb !important;
border-radius: 0px;
cursor: pointer;
}
.c0 .localeDropdownMenu > button:first-child {
line-height: 50px;
margin-bottom: 4px;
}
.c0 .localeDropdownMenu > button:first-child:hover,
.c0 .localeDropdownMenu > button:first-child:active {
color: #333740;
}
.c0 .localeDropdownMenu > button:not(:first-child) {
height: 36px;
line-height: 36px;
}
.c0 .localeDropdownMenu > button:not(:first-child) > i,
.c0 .localeDropdownMenu > button:not(:first-child) > svg {
margin-left: 10px;
}
.c0 .localeDropdownMenuNotLogged {
background: transparent !important;
box-shadow: none !important;
border: 1px solid #e3e9f3 !important;
border-top: 0px !important;
}
.c0 .localeDropdownMenuNotLogged button {
padding-left: 17px;
}
.c0 .localeDropdownMenuNotLogged button:hover {
background-color: #f7f8f8 !important;
}
.c0 .localeDropdownMenuNotLogged:before {
box-shadow: none !important;
}
.c0 .localeToggleItem img {
max-height: 13.37px;
margin-left: 9px;
}
.c0 .localeToggleItem:active {
color: black;
}
.c0 .localeToggleItem:hover {
background-color: #fafafb !important;
}
.c0 .localeToggleItemActive {
color: #333740 !important;
}
<div
class="c0"
>
<div
class="btn-group"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="localeDropdownContent btn btn-secondary"
type="button"
>
<span>
English
</span>
</button>
<div
aria-hidden="true"
class="localeDropdownMenu dropdown-menu"
role="menu"
tabindex="-1"
>
<button
class="localeToggleItem localeToggleItemActive dropdown-item"
role="menuitem"
tabindex="0"
type="button"
>
English
</button>
</div>
</div>
</div>
`);
});
});

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
const LocalesProviderContext = createContext();
export default LocalesProviderContext;

View File

@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import LocalesProviderContext from './context';
const LocalesProvider = ({ changeLocale, children, localeNames, messages }) => {
return (
<LocalesProviderContext.Provider value={{ changeLocale, localeNames, messages }}>
{children}
</LocalesProviderContext.Provider>
);
};
LocalesProvider.propTypes = {
changeLocale: PropTypes.func.isRequired,
children: PropTypes.element.isRequired,
localeNames: PropTypes.object.isRequired,
messages: PropTypes.object.isRequired,
};
export default LocalesProvider;

View File

@ -0,0 +1,23 @@
import React from 'react';
import { render } from '@testing-library/react';
import LocalesProvider from '../index';
describe('LocalesProvider', () => {
it('should not crash', () => {
const { container } = render(
<LocalesProvider
changeLocale={jest.fn()}
localeNames={{ en: 'English' }}
messages={{ en: {} }}
>
<div>Test</div>
</LocalesProvider>
);
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
Test
</div>
`);
});
});

View File

@ -0,0 +1,10 @@
import { useContext } from 'react';
import LocalesProviderContext from './context';
const useLocalesProvider = () => {
const { changeLocale, localeNames, messages } = useContext(LocalesProviderContext);
return { changeLocale, localeNames, messages };
};
export default useLocalesProvider;

View File

@ -1,18 +1,26 @@
import React, { useEffect, useReducer, memo } from 'react';
import React, { useEffect, useReducer } from 'react';
import { FormattedMessage } from 'react-intl';
import axios from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faQuestion, faTimes } from '@fortawesome/free-solid-svg-icons';
import cn from 'classnames';
import { useGlobalContext } from '@strapi/helper-plugin';
import { useTracking } from '@strapi/helper-plugin';
import formatVideoArray from './utils/formatAndStoreVideoArray';
import StaticLinks from './StaticLinks';
import Video from './Video';
import Wrapper from './Wrapper';
import reducer, { initialState } from './reducer';
const Onboarding = () => {
if (process.env.STRAPI_ADMIN_SHOW_TUTORIALS !== 'true') {
return null;
}
return <OnboardingVideos />;
};
const OnboardingVideos = () => {
const { emitEvent } = useGlobalContext();
const { trackUsage } = useTracking();
const [{ isLoading, isOpen, videos }, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
@ -50,7 +58,7 @@ const OnboardingVideos = () => {
: 'didCloseGetStartedVideoContainer';
dispatch({ type: 'SET_IS_OPEN' });
emitEvent(eventName);
trackUsage(eventName);
};
const handleClickOpenVideo = videoIndexToOpen => {
dispatch({
@ -104,12 +112,12 @@ const OnboardingVideos = () => {
didPlayVideo={(_, elapsedTime) => {
const eventName = `didPlay${index}GetStartedVideo`;
emitEvent(eventName, { timestamp: elapsedTime });
trackUsage(eventName, { timestamp: elapsedTime });
}}
didStopVideo={(_, elapsedTime) => {
const eventName = `didStop${index}Video`;
emitEvent(eventName, { timestamp: elapsedTime });
trackUsage(eventName, { timestamp: elapsedTime });
}}
/>
))}
@ -127,4 +135,4 @@ const OnboardingVideos = () => {
);
};
export default memo(OnboardingVideos);
export default Onboarding;

View File

@ -4,12 +4,12 @@ import PropTypes from 'prop-types';
import favicon from '../../favicon.png';
const PageTitle = ({ title }) => (
<Helmet title={title} link={[{ rel: 'icon', type: 'image/png', href: favicon }]} />
);
const PageTitle = ({ title }) => {
return <Helmet title={title} link={[{ rel: 'icon', type: 'image/png', href: favicon }]} />;
};
PageTitle.propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired,
};
export default memo(PageTitle);

View File

@ -1,25 +0,0 @@
import {
GET_USER_PERMISSIONS,
GET_USER_PERMISSIONS_SUCCEEDED,
GET_USER_PERMISSIONS_ERROR,
} from './constants';
export function getUserPermissions() {
return {
type: GET_USER_PERMISSIONS,
};
}
export function getUserPermissionsError(error) {
return {
type: GET_USER_PERMISSIONS_ERROR,
error,
};
}
export function getUserPermissionsSucceeded(data) {
return {
type: GET_USER_PERMISSIONS_SUCCEEDED,
data,
};
}

View File

@ -1,5 +0,0 @@
export const GET_USER_PERMISSIONS = 'StrapiAdmin/PermissionsManager/GET_USER_PERMISSIONS';
export const GET_USER_PERMISSIONS_ERROR =
'StrapiAdmin/PermissionsManager/GET_USER_PERMISSIONS_ERROR';
export const GET_USER_PERMISSIONS_SUCCEEDED =
'StrapiAdmin/PermissionsManager/GET_USER_PERMISSIONS_SUCCEEDED';

View File

@ -1,49 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { LoadingIndicatorPage, UserProvider, request } from '@strapi/helper-plugin';
import {
getUserPermissions,
getUserPermissionsError,
getUserPermissionsSucceeded,
} from './actions';
const PermissionsManager = ({ children }) => {
const { isLoading, userPermissions } = useSelector(state => state.permissionsManager);
const dispatch = useDispatch();
const fetchUserPermissions = async (resetState = false) => {
if (resetState) {
// Show a loader
dispatch(getUserPermissions());
}
try {
const { data } = await request('/admin/users/me/permissions', { method: 'GET' });
dispatch(getUserPermissionsSucceeded(data));
} catch (err) {
console.error(err);
dispatch(getUserPermissionsError(err));
}
};
useEffect(() => {
fetchUserPermissions(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isLoading) {
return <LoadingIndicatorPage />;
}
return <UserProvider value={{ userPermissions, fetchUserPermissions }}>{children}</UserProvider>;
};
PermissionsManager.defaultProps = {};
PermissionsManager.propTypes = {
children: PropTypes.node.isRequired,
};
export default PermissionsManager;

View File

@ -1,103 +0,0 @@
import produce from 'immer';
import {
getUserPermissions,
getUserPermissionsError,
getUserPermissionsSucceeded,
} from '../actions';
import permissionsManagerReducer from '../reducer';
describe('permissionsManagerReducer', () => {
let state;
beforeEach(() => {
state = {
isLoading: true,
userPermissions: [],
collectionTypesRelatedPermissions: {},
};
});
it('returns the initial state', () => {
const expected = state;
expect(permissionsManagerReducer(undefined, {})).toEqual(expected);
});
it('should handle the getUserPermissions action correctly', () => {
state.userPermissions = ['test'];
state.collectionTypesRelatedPermissions = null;
const expected = produce(state, draft => {
draft.isLoading = true;
draft.userPermissions = [];
draft.collectionTypesRelatedPermissions = {};
});
expect(permissionsManagerReducer(state, getUserPermissions())).toEqual(expected);
});
it('should handle the getUserPermissionsError action correctly', () => {
const error = 'Error';
const expected = produce(state, draft => {
draft.isLoading = false;
draft.error = error;
});
expect(permissionsManagerReducer(state, getUserPermissionsError(error))).toEqual(expected);
});
it('should handle the getUserPermissionsSucceeded action correctly', () => {
const data = [
{
action: 'create',
subject: 'address',
properties: {
fields: ['f1'],
},
conditions: [],
},
{
action: 'create',
subject: 'address',
properties: {
fields: ['f2'],
},
conditions: [],
},
{
action: 'tes',
subject: null,
properties: {},
conditions: [],
},
];
const expected = produce(state, draft => {
draft.isLoading = false;
draft.userPermissions = data;
draft.collectionTypesRelatedPermissions = {
address: {
create: [
{
action: 'create',
subject: 'address',
properties: {
fields: ['f1'],
},
conditions: [],
},
{
action: 'create',
subject: 'address',
properties: {
fields: ['f2'],
},
conditions: [],
},
],
},
};
});
expect(permissionsManagerReducer(state, getUserPermissionsSucceeded(data))).toEqual(expected);
});
});

View File

@ -1,12 +1,12 @@
import React, { useReducer, useRef } from 'react';
import { LoadingIndicatorPage, useStrapiApp } from '@strapi/helper-plugin';
import Admin from '../../pages/Admin';
import init from './init';
import reducer, { initialState } from './reducer';
const PluginsInitializer = () => {
const { plugins: appPlugins } = useStrapiApp();
const [{ plugins }, dispatch] = useReducer(reducer, initialState, () => init(appPlugins));
const setPlugin = useRef(pluginId => {
dispatch({ type: 'SET_PLUGIN_READY', pluginId });
@ -35,7 +35,7 @@ const PluginsInitializer = () => {
);
}
return <Admin plugins={plugins} />;
return <Admin />;
};
export default PluginsInitializer;

View File

@ -32,7 +32,7 @@ const PrivateRoute = ({ component: Component, path, ...rest }) => (
);
PrivateRoute.propTypes = {
component: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
component: PropTypes.any.isRequired,
path: PropTypes.string.isRequired,
};

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