Merge branch 'features/typescript' into typescript/telemetry

This commit is contained in:
Convly 2022-04-04 17:33:07 +02:00
commit 77daaf7e72
389 changed files with 4228 additions and 3387 deletions

2
.github/FUNDING.yml vendored
View File

@ -1 +1 @@
open_collective: strapi
open_collective: strapi

View File

@ -18,6 +18,17 @@ https://github.com/strapi/strapi/blob/master/CONTRIBUTING.md#reporting-an-issue
## Bug report
### Required System information
<!-- Please ensure you are using the Node LTS version (v14 or v16) -->
<!-- Strapi v3 is not supported unless it is a critical/high security issue -->
- Node.js version:
- NPM version:
- Strapi version:
- Database:
- Operating system:
### Describe the bug
A clear and concise description of what the bug is.
@ -41,14 +52,6 @@ If applicable, add screenshots to help explain your problem.
If applicable, add code samples to help explain your problem.
### System
- Node.js version: <!-- Please ensure you are using the Node LTS version (v12, v14, v16) -->
- NPM version:
- Strapi version: <!-- v3 is not supported unless it is a critical/high security issue -->
- Database:
- Operating system:
### Additional context
Add any other context about the problem here.

View File

@ -1,35 +0,0 @@
---
name: 🚀 Feature Request
about: Suggest an idea to help make Strapi even better!
---
<!--
Hello 👋 Thank you for submitting a feature request.
We are using ProductBoard to manage our roadmap and feature requests.
Can you please submit your feature request here: https://portal.productboard.com/strapi
-->
## Feature request
### Please describe your feature request
- [ ] **I have created my request on the Product Board before I submitted this issue**
- [ ] **I have looked at all the other requests on the Product Board before I submitted this issue**
### Summary
Quick summary what's this feature request about.
### Why is it needed?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
### Suggested solution(s)
A clear and concise description of what you want to happen.
### Related issue(s)/PR(s)
Let us know if this is related to any issue/pull request.

View File

@ -1,11 +1,17 @@
blank_issues_enabled: false
contact_links:
- name: Product Feature Request
url: https://feedback.strapi.io/feature-requests
about: Provide feedback to the Strapi team and ask for new features or enhancements!
- name: Documentation Bug Report
url: https://github.com/strapi/documentation/issues/new?template=BUG_REPORT.md&title%5B%5D=BUG
about: Create a report to help us improve the Strapi documentation.
- name: Documentation Request
url: https://github.com/strapi/documentation/issues/new?template=DOC_REQUEST.md&title%5B%5D=REQUEST
url: https://feedback.strapi.io/documentation
about: Suggest a new part of the documentation we are missing!
- name: Strapi Questions and Discussions
url: https://forum.strapi.io
about: Please ask and answer questions on our forums.
about: Please ask and answer questions on the community forums.
- name: Join the Community Discord
url: https://discord.strapi.io
about: Come and chat with other community members!

View File

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

View File

@ -7,5 +7,5 @@ export ENV_PATH="$(pwd)/testApp/.env"
opts=($DB_OPTIONS)
yarn run -s test:generate-app "${opts[@]}" $@
yarn run -s test:e2e
yarn run -s test:generate-app "${opts[@]}"
yarn run -s test:e2e $@

View File

@ -15,9 +15,10 @@ jobs:
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
cache: yarn
- uses: ./.github/actions/install-modules
- name: Run lint
run: yarn run -s lint
@ -33,9 +34,10 @@ jobs:
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
cache: yarn
- uses: ./.github/actions/install-modules
with:
globalPackages: codecov
@ -53,9 +55,10 @@ jobs:
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
cache: yarn
- uses: ./.github/actions/install-modules
with:
globalPackages: codecov
@ -71,7 +74,6 @@ jobs:
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
services:
postgres:
# Docker Hub image
@ -92,9 +94,10 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
cache: yarn
- uses: ./.github/actions/install-modules
- uses: ./.github/actions/run-e2e-tests
with:
@ -107,7 +110,6 @@ jobs:
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
services:
mysql:
image: mysql
@ -143,7 +145,6 @@ jobs:
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
services:
mysql:
image: mysql:5
@ -175,11 +176,11 @@ jobs:
e2e_ce_sqlite:
runs-on: ubuntu-latest
needs: [lint, unit_back, unit_front]
name: '[CE] E2E (sqlite, node: ${{ matrix.node }})'
name: '[CE] E2E (sqlite: ${{ matrix.sqlite_pkg }}, node: ${{ matrix.node }})'
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
sqlite_pkg: ['better-sqlite3', 'sqlite3', '@vscode/sqlite3']
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
@ -188,9 +189,10 @@ jobs:
cache: yarn
- uses: ./.github/actions/install-modules
- uses: ./.github/actions/run-e2e-tests
env:
SQLITE_PKG: ${{ matrix.sqlite_pkg }}
with:
dbOptions: '--dbclient=sqlite --dbfile=./tmp/data.db'
dbOptions: '--dbclient=sqlite-legacy --dbfile=./tmp/data.db'
# EE
e2e_ee_pg:
runs-on: ubuntu-latest
@ -202,7 +204,6 @@ jobs:
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
services:
postgres:
# Docker Hub image
@ -243,7 +244,6 @@ jobs:
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
services:
mysql:
image: mysql
@ -276,14 +276,14 @@ jobs:
e2e_ee_sqlite:
runs-on: ubuntu-latest
needs: [lint, unit_back, unit_front]
name: '[EE] E2E (sqlite, node: ${{ matrix.node }})'
name: '[EE] E2E (sqlite: ${{ matrix.sqlite_pkg }}, node: ${{ matrix.node }})'
if: github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]')
env:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
matrix:
node: [12, 14, 16]
max-parallel: 3
sqlite_pkg: ['better-sqlite3', 'sqlite3', '@vscode/sqlite3']
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
@ -292,6 +292,8 @@ jobs:
cache: yarn
- uses: ./.github/actions/install-modules
- uses: ./.github/actions/run-e2e-tests
env:
SQLITE_PKG: ${{ matrix.sqlite_pkg }}
with:
dbOptions: '--dbclient=sqlite --dbfile=./tmp/data.db'
runEE: true

View File

@ -1,7 +1,7 @@
{
"name": "getstarted",
"private": true,
"version": "4.1.4",
"version": "4.1.7",
"description": "A Strapi application.",
"scripts": {
"develop": "strapi develop",
@ -12,17 +12,19 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/admin": "4.1.4",
"@strapi/plugin-documentation": "4.1.4",
"@strapi/plugin-graphql": "4.1.4",
"@strapi/plugin-i18n": "4.1.4",
"@strapi/plugin-sentry": "4.1.4",
"@strapi/plugin-users-permissions": "4.1.4",
"@strapi/provider-email-mailgun": "4.1.4",
"@strapi/provider-upload-aws-s3": "4.1.4",
"@strapi/provider-upload-cloudinary": "4.1.4",
"@strapi/strapi": "4.1.4",
"@strapi/utils": "4.1.4",
"@strapi/admin": "4.1.7",
"@strapi/plugin-documentation": "4.1.7",
"@strapi/plugin-graphql": "4.1.7",
"@strapi/plugin-i18n": "4.1.7",
"@strapi/plugin-sentry": "4.1.7",
"@strapi/plugin-users-permissions": "4.1.7",
"@strapi/provider-email-mailgun": "4.1.7",
"@strapi/provider-upload-aws-s3": "4.1.7",
"@strapi/provider-upload-cloudinary": "4.1.7",
"@strapi/strapi": "4.1.7",
"@strapi/utils": "4.1.7",
"@vscode/sqlite3": "5.0.8",
"better-sqlite3": "7.4.6",
"lodash": "4.17.21",
"mysql": "2.18.1",
"passport-google-oauth2": "0.2.0",

View File

@ -1,7 +1,7 @@
{
"name": "kitchensink-ts",
"private": true,
"version": "4.2.0-beta.0",
"version": "4.1.7",
"description": "A Strapi application.",
"scripts": {
"develop": "strapi develop",
@ -12,12 +12,12 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/admin": "4.1.4",
"@strapi/provider-email-mailgun": "4.1.4",
"@strapi/provider-upload-aws-s3": "4.1.4",
"@strapi/provider-upload-cloudinary": "4.1.4",
"@strapi/strapi": "4.1.4",
"@strapi/utils": "4.1.4",
"@strapi/admin": "4.1.7",
"@strapi/provider-email-mailgun": "4.1.7",
"@strapi/provider-upload-aws-s3": "4.1.7",
"@strapi/provider-upload-cloudinary": "4.1.7",
"@strapi/strapi": "4.1.7",
"@strapi/utils": "4.1.7",
"lodash": "4.17.21",
"mysql": "2.18.1",
"passport-google-oauth2": "0.2.0",

View File

@ -1,7 +1,7 @@
{
"name": "kitchensink",
"private": true,
"version": "4.2.0-beta.0",
"version": "4.1.7",
"description": "A Strapi application.",
"scripts": {
"develop": "strapi develop",
@ -12,12 +12,12 @@
"strapi": "strapi"
},
"dependencies": {
"@strapi/admin": "4.2.0-beta.0",
"@strapi/provider-email-mailgun": "4.2.0-beta.0",
"@strapi/provider-upload-aws-s3": "4.2.0-beta.0",
"@strapi/provider-upload-cloudinary": "4.2.0-beta.0",
"@strapi/strapi": "4.2.0-beta.0",
"@strapi/utils": "4.2.0-beta.0",
"@strapi/admin": "4.1.7",
"@strapi/provider-email-mailgun": "4.1.7",
"@strapi/provider-upload-aws-s3": "4.1.7",
"@strapi/provider-upload-cloudinary": "4.1.7",
"@strapi/strapi": "4.1.7",
"@strapi/utils": "4.1.7",
"lodash": "4.17.21",
"mysql": "2.18.1",
"passport-google-oauth2": "0.2.0",

View File

@ -1,5 +1,5 @@
{
"version": "4.2.0-beta.0",
"version": "4.1.7",
"packages": [
"packages/*",
"examples/*"

View File

@ -90,7 +90,7 @@
"eslint-plugin-jsx-a11y": "6.5.1",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
"eslint-plugin-react-hooks": "4.4.0",
"eslint-plugin-redux-saga": "1.3.2",
"execa": "1.0.0",
"fs-extra": "10.0.1",

View File

@ -15,6 +15,7 @@ const reducers = {
'content-manager_listView': jest.fn(() => ({
data: [],
isLoading: true,
components: [],
contentType: {},
initialDisplayedHeaders: [],
displayedHeaders: [],

View File

@ -1,6 +1,6 @@
{
"name": "@strapi/admin-test-utils",
"version": "4.2.0-beta.0",
"version": "4.1.7",
"private": true,
"description": "Test utilities for the Strapi administration panel",
"license": "MIT",
@ -21,7 +21,7 @@
"@babel/polyfill": "7.12.1"
},
"devDependencies": {
"@testing-library/jest-dom": "5.16.2",
"@testing-library/jest-dom": "5.16.3",
"jest-styled-components": "7.0.2"
},
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi-app",
"version": "4.1.4",
"version": "4.1.7",
"description": "Generate a new Strapi application.",
"keywords": [
"create-strapi-app",
@ -38,7 +38,7 @@
"test": "echo \"no tests yet\""
},
"dependencies": {
"@strapi/generate-new": "4.1.4",
"@strapi/generate-new": "4.1.7",
"commander": "6.1.0",
"inquirer": "8.2.0"
},

View File

@ -1,6 +1,6 @@
{
"name": "create-strapi-starter",
"version": "4.1.4",
"version": "4.1.7",
"description": "Generate a new Strapi application.",
"keywords": [
"create-strapi-starter",
@ -38,7 +38,7 @@
"test": "echo \"no tests yet\""
},
"dependencies": {
"@strapi/generate-new": "4.1.4",
"@strapi/generate-new": "4.1.7",
"chalk": "4.1.1",
"ci-info": "3.1.1",
"commander": "7.1.0",

View File

@ -0,0 +1,5 @@
<svg width="88" height="88" viewBox="0 0 88 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="87" height="87" rx="43.5" fill="#F0F0FF"/>
<path d="M34.0469 39.2969H30C27.4479 39.2969 25.2604 40.2448 23.4375 42.1406C21.6146 43.9635 20.7031 46.151 20.7031 48.7031C20.7031 51.2552 21.6146 53.4427 23.4375 55.2656C25.2604 57.0885 27.4479 58 30 58H52.75L34.0469 39.2969ZM23 28.25L25.9531 25.2969L65 64.3438L62.0469 67.2969L57.3438 62.7031H30C26.1354 62.7031 22.8177 61.3542 20.0469 58.6562C17.349 55.8854 16 52.5677 16 48.7031C16 44.9115 17.3125 41.6667 19.9375 38.9688C22.5625 36.2708 25.7344 34.849 29.4531 34.7031L23 28.25ZM61.1719 39.4062C64.1615 39.625 66.7135 40.8646 68.8281 43.125C70.9427 45.3125 72 47.9375 72 51C72 55.0104 70.3594 58.1823 67.0781 60.5156L63.6875 57.125C66.0938 55.8125 67.2969 53.7708 67.2969 51C67.2969 49.1042 66.6042 47.4635 65.2188 46.0781C63.8333 44.6927 62.1927 44 60.2969 44H56.7969V42.7969C56.7969 39.224 55.5573 36.1979 53.0781 33.7188C50.599 31.2396 47.5729 30 44 30C41.9583 30 39.9896 30.474 38.0938 31.4219L34.5938 28.0312C37.4375 26.2083 40.5729 25.2969 44 25.2969C47.9375 25.2969 51.5833 26.6823 54.9375 29.4531C58.3646 32.224 60.4427 35.5417 61.1719 39.4062Z" fill="#4945FF"/>
<rect x="0.5" y="0.5" width="87" height="87" rx="43.5" stroke="#D9D8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -2,7 +2,7 @@ import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { QueryClientProvider, QueryClient } from 'react-query';
import { useGuidedTour } from '@strapi/helper-plugin';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import { ConfigurationsContext } from '../../../contexts';
import {
fetchAppInfo,
@ -48,7 +48,7 @@ const queryClient = new QueryClient({
});
const app = (
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<QueryClientProvider client={queryClient}>
<ConfigurationsContext.Provider value={{ showReleaseNotification: false }}>

View File

@ -21,7 +21,7 @@ const fetchStrapiLatestRelease = async toggleNotification => {
link: {
url: `https://github.com/strapi/strapi/releases/tag/${tag_name}`,
label: {
id: 'notification.version.update.link',
id: 'global.see-more',
},
},
blockTransition: true,

View File

@ -78,7 +78,7 @@ const Blocker = ({ displayedIcon, description, title, isOpen }) => {
rel="noopener noreferrer nofollow"
>
{formatMessage({
id: 'app.components.BlockLink.documentation',
id: 'global.documentation',
defaultMessage: 'Read the documentation',
})}
</Link>

View File

@ -2,7 +2,7 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { useGuidedTour } from '@strapi/helper-plugin';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../Theme';
import ThemeToggleProvider from '../../../ThemeToggleProvider';
import GuidedTourModal from '../index';
@ -30,7 +30,7 @@ jest.mock('@strapi/helper-plugin', () => ({
}));
const App = (
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" messages={{}} defaultLocale="en" textComponent="span">
<GuidedTourModal />

View File

@ -100,7 +100,7 @@ const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }) => {
<NavSections>
<NavLink to="/content-manager" icon={<Write />}>
{formatMessage({ id: 'content-manager.plugin.name', defaultMessage: 'Content manager' })}
{formatMessage({ id: 'global.content-manager', defaultMessage: 'Content manager' })}
</NavLink>
{pluginsSectionLinks.length > 0 ? (
@ -160,7 +160,7 @@ const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }) => {
<LinkUser tabIndex={0} onClick={handleToggleUserLinks} to="/me">
<Typography>
{formatMessage({
id: 'app.components.LeftMenu.profile',
id: 'global.profile',
defaultMessage: 'Profile',
})}
</Typography>

View File

@ -9,7 +9,7 @@ import { render, fireEvent, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { useNotification } from '@strapi/helper-plugin';
import { act } from 'react-dom/test-utils';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../Theme';
import ThemeToggleProvider from '../../ThemeToggleProvider';
import Notifications from '../index';
@ -21,7 +21,7 @@ describe('<Notifications />', () => {
const {
container: { firstChild },
} = render(
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" messages={messages} defaultLocale="en" textComponent="span">
<Notifications>
@ -85,7 +85,7 @@ describe('<Notifications />', () => {
};
render(
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" defaultLocale="en" messages={messages} textComponent="span">
<Notifications>
@ -128,7 +128,7 @@ describe('<Notifications />', () => {
};
render(
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<IntlProvider locale="en" defaultLocale="en" messages={messages} textComponent="span">
<Notifications>

View File

@ -203,8 +203,6 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
const displayErrors = useCallback(
err => {
const errorPayload = err.response.data;
console.error(errorPayload);
let errorMessage = get(errorPayload, ['error', 'message'], 'Bad Request');
// TODO handle errors correctly when back-end ready
@ -272,10 +270,14 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(setStatus('resolved'));
replace(`/content-manager/collectionType/${slug}/${data.id}${rawQuery}`);
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
displayErrors(err);
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[
@ -308,9 +310,13 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
type: 'success',
message: { id: getTrad('success.record.publish') },
});
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, id, slug, dispatch, toggleNotification]);
@ -334,11 +340,15 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotEditEntry', { error: err, trackerProperty });
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, id, dispatch, toggleNotification]
@ -362,9 +372,13 @@ const CollectionTypeFormWrapper = ({ allLayoutData, children, slug, id, origin }
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
dispatch(setStatus('resolved'));
displayErrors(err);
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, id, slug, dispatch, toggleNotification]);

View File

@ -57,10 +57,13 @@ const ComponentInitializer = ({ error, isReadOnly, onClick }) => {
</Box>
{error?.id && (
<Typography textColor="danger600" variant="pi">
{formatMessage({
id: error.id,
defaultMessage: error.id,
})}
{formatMessage(
{
id: error.id,
defaultMessage: error.id,
},
{ ...error.values }
)}
</Typography>
)}
</>
@ -75,6 +78,8 @@ ComponentInitializer.defaultProps = {
ComponentInitializer.propTypes = {
error: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
values: PropTypes.object,
}),
isReadOnly: PropTypes.bool,
onClick: PropTypes.func.isRequired,

View File

@ -1,87 +0,0 @@
import React from 'react';
import { useQuery } from 'react-query';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { Loader } from '@strapi/design-system/Loader';
import { useNotifyAT } from '@strapi/design-system/LiveRegions';
import { axiosInstance } from '../../../../../core/utils';
import { getRequestUrl, getTrad } from '../../../../utils';
import CellValue from '../CellValue';
const fetchRelation = async (endPoint, notifyStatus) => {
const {
data: { results, pagination },
} = await axiosInstance.get(endPoint);
notifyStatus();
return { results, pagination };
};
const PopoverContent = ({ fieldSchema, name, rowId, targetModel, queryInfos }) => {
const requestURL = getRequestUrl(`${queryInfos.endPoint}/${rowId}/${name.split('.')[0]}`);
const { notifyStatus } = useNotifyAT();
const { formatMessage } = useIntl();
const notify = () => {
const message = formatMessage({
id: getTrad('DynamicTable.relation-loaded'),
defaultMessage: 'The relations have been loaded',
});
notifyStatus(message);
};
const { data, status } = useQuery([targetModel, rowId], () => fetchRelation(requestURL, notify), {
staleTime: 0,
});
if (status !== 'success') {
return (
<Box>
<Loader>Loading content</Loader>
</Box>
);
}
return (
<ul>
{data?.results.map(entry => {
const value = entry[fieldSchema.name];
return (
<Box as="li" key={entry.id} padding={3}>
<Typography>
{value ? (
<CellValue type={fieldSchema.schema.type} value={entry[fieldSchema.name]} />
) : (
'-'
)}
</Typography>
</Box>
);
})}
{data?.pagination.total > 10 && (
<Box as="li" padding={3}>
<Typography>[...]</Typography>
</Box>
)}
</ul>
);
};
PopoverContent.propTypes = {
fieldSchema: PropTypes.shape({
name: PropTypes.string,
schema: PropTypes.shape({ type: PropTypes.string }).isRequired,
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
targetModel: PropTypes.string.isRequired,
queryInfos: PropTypes.shape({
endPoint: PropTypes.string,
}).isRequired,
};
export default PopoverContent;

View File

@ -1,107 +0,0 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { IconButton } from '@strapi/design-system/IconButton';
import { Typography } from '@strapi/design-system/Typography';
import { Box } from '@strapi/design-system/Box';
import { Badge } from '@strapi/design-system/Badge';
import { Flex } from '@strapi/design-system/Flex';
import { Popover } from '@strapi/design-system/Popover';
import { SortIcon, stopPropagation } from '@strapi/helper-plugin';
import styled from 'styled-components';
import PopoverContent from './PopoverContent';
import CellValue from '../CellValue';
const SINGLE_RELATIONS = ['oneToOne', 'manyToOne'];
const ActionWrapper = styled.span`
svg {
height: ${4 / 16}rem;
}
`;
const RelationCountBadge = styled(Badge)`
display: flex;
align-items: center;
height: ${20 / 16}rem;
width: ${16 / 16}rem;
`;
const Relation = ({ fieldSchema, metadatas, queryInfos, name, rowId, value }) => {
const { formatMessage } = useIntl();
const [visible, setVisible] = useState(false);
const buttonRef = useRef();
if (SINGLE_RELATIONS.includes(fieldSchema.relation)) {
return (
<Typography textColor="neutral800">
<CellValue type={metadatas.mainField.schema.type} value={value[metadatas.mainField.name]} />
</Typography>
);
}
const handleTogglePopover = () => setVisible(prev => !prev);
return (
<Flex {...stopPropagation}>
<RelationCountBadge>{value.count}</RelationCountBadge>
<Box paddingLeft={2}>
<Typography textColor="neutral800">
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.count }
)}
</Typography>
</Box>
{value.count > 0 && (
<ActionWrapper>
<IconButton
onClick={handleTogglePopover}
ref={buttonRef}
noBorder
label={formatMessage({
id: 'content-manager.popover.display-relations.label',
defaultMessage: 'Display relations',
})}
icon={<SortIcon isUp={visible} />}
/>
{visible && (
<Popover source={buttonRef} spacing={16} centered>
<PopoverContent
queryInfos={queryInfos}
name={name}
fieldSchema={metadatas.mainField}
targetModel={fieldSchema.targetModel}
rowId={rowId}
count={value.count}
/>
</Popover>
)}
</ActionWrapper>
)}
</Flex>
);
};
Relation.propTypes = {
fieldSchema: PropTypes.shape({
relation: PropTypes.string,
targetModel: PropTypes.string,
type: PropTypes.string.isRequired,
}).isRequired,
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
queryInfos: PropTypes.shape({ endPoint: PropTypes.string.isRequired }).isRequired,
value: PropTypes.object.isRequired,
};
export default Relation;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useQuery } from 'react-query';
import { useIntl } from 'react-intl';
import { Typography } from '@strapi/design-system/Typography';
import { Box } from '@strapi/design-system/Box';
import { Badge } from '@strapi/design-system/Badge';
import { SimpleMenu, MenuItem } from '@strapi/design-system/SimpleMenu';
import { Loader } from '@strapi/design-system/Loader';
import styled from 'styled-components';
import { useNotifyAT } from '@strapi/design-system/LiveRegions';
import { stopPropagation } from '@strapi/helper-plugin';
import CellValue from '../CellValue';
import { axiosInstance } from '../../../../../core/utils';
import { getRequestUrl, getTrad } from '../../../../utils';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const fetchRelation = async (endPoint, notifyStatus) => {
const {
data: { results, pagination },
} = await axiosInstance.get(endPoint);
notifyStatus();
return { results, pagination };
};
const RelationMultiple = ({ fieldSchema, metadatas, queryInfos, name, rowId, value }) => {
const { formatMessage } = useIntl();
const { notifyStatus } = useNotifyAT();
const requestURL = getRequestUrl(`${queryInfos.endPoint}/${rowId}/${name.split('.')[0]}`);
const [isOpen, setIsOpen] = useState(false);
const Label = (
<>
<Badge>{value.count}</Badge>{' '}
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.count }
)}
</>
);
const notify = () => {
const message = formatMessage({
id: getTrad('DynamicTable.relation-loaded'),
defaultMessage: 'Relations have been loaded',
});
notifyStatus(message);
};
const { data, status } = useQuery(
[fieldSchema.targetModel, rowId],
() => fetchRelation(requestURL, notify),
{
enabled: isOpen,
staleTime: 0,
}
);
return (
<Box {...stopPropagation}>
<SimpleMenu
label={Label}
size="S"
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
>
{status !== 'success' && (
<MenuItem aria-disabled>
<Loader small>
{formatMessage({
id: getTrad('DynamicTable.relation-loading'),
defaultMessage: 'Relations are loading',
})}
</Loader>
</MenuItem>
)}
{status === 'success' && (
<>
{data?.results.map(entry => (
<MenuItem key={entry.id} aria-disabled>
<TypographyMaxWidth ellipsis>
<CellValue
type={metadatas.mainField.schema.type}
value={entry[metadatas.mainField.name] || entry.id}
/>
</TypographyMaxWidth>
</MenuItem>
))}
{data?.pagination.total > 10 && (
<MenuItem
aria-disabled
aria-label={formatMessage({
id: getTrad('DynamicTable.relation-more'),
defaultMessage: 'This relation contains more entities than displayed',
})}
>
<Typography>...</Typography>
</MenuItem>
)}
</>
)}
</SimpleMenu>
</Box>
);
};
RelationMultiple.propTypes = {
fieldSchema: PropTypes.shape({
relation: PropTypes.string,
targetModel: PropTypes.string,
type: PropTypes.string.isRequired,
}).isRequired,
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
queryInfos: PropTypes.shape({ endPoint: PropTypes.string.isRequired }).isRequired,
value: PropTypes.object.isRequired,
};
export default RelationMultiple;

View File

@ -0,0 +1,291 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DynamicTabe / Cellcontent / RelationMultiple renders and matches the snapshot 1`] = `
.c11 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.c5 {
background: #f6f6f9;
padding: 4px;
border-radius: 4px;
min-width: 20px;
}
.c6 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c7 {
color: #666687;
font-weight: 600;
font-size: 0.6875rem;
line-height: 1.45;
text-transform: uppercase;
}
.c4 {
font-weight: 600;
color: #32324d;
font-size: 0.75rem;
line-height: 1.33;
}
.c9 {
padding-left: 8px;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
cursor: pointer;
padding: 8px;
border-radius: 4px;
background: #ffffff;
border: 1px solid #dcdce4;
position: relative;
outline: none;
}
.c0 svg {
height: 12px;
width: 12px;
}
.c0 svg > g,
.c0 svg path {
fill: #ffffff;
}
.c0[aria-disabled='true'] {
pointer-events: none;
}
.c0:after {
-webkit-transition-property: all;
transition-property: all;
-webkit-transition-duration: 0.2s;
transition-duration: 0.2s;
border-radius: 8px;
content: '';
position: absolute;
top: -4px;
bottom: -4px;
left: -4px;
right: -4px;
border: 2px solid transparent;
}
.c0:focus-visible {
outline: none;
}
.c0:focus-visible:after {
border-radius: 8px;
content: '';
position: absolute;
top: -5px;
bottom: -5px;
left: -5px;
right: -5px;
border: 2px solid #4945ff;
}
.c1 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 16px;
background: #4945ff;
border: none;
border: 1px solid transparent;
background: transparent;
}
.c1 .c8 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c1 .c3 {
color: #ffffff;
}
.c1[aria-disabled='true'] {
border: 1px solid #dcdce4;
background: #eaeaef;
}
.c1[aria-disabled='true'] .c3 {
color: #666687;
}
.c1[aria-disabled='true'] svg > g,
.c1[aria-disabled='true'] svg path {
fill: #666687;
}
.c1[aria-disabled='true']:active {
border: 1px solid #dcdce4;
background: #eaeaef;
}
.c1[aria-disabled='true']:active .c3 {
color: #666687;
}
.c1[aria-disabled='true']:active svg > g,
.c1[aria-disabled='true']:active svg path {
fill: #666687;
}
.c1:hover {
background-color: #f6f6f9;
}
.c1:active {
border: 1px solid undefined;
background: undefined;
}
.c1 .c3 {
color: #32324d;
}
.c1 svg > g,
.c1 svg path {
fill: #8e8ea9;
}
.c10 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c10 svg {
height: 4px;
width: 6px;
}
.c2 {
padding: 4px 12px;
}
<div>
<div
aria-hidden="true"
class=""
role="button"
>
<div>
<button
aria-controls="simplemenu-1"
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
class="c0 c1 c2"
type="button"
>
<span
class="c3 c4"
>
<div
class="c5 c6"
>
<span
class="c7"
>
1
</span>
</div>
item
</span>
<div
aria-hidden="true"
class="c8 c9"
>
<span
class="c10"
>
<svg
aria-hidden="true"
fill="none"
height="1em"
viewBox="0 0 14 8"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M14 .889a.86.86 0 01-.26.625L7.615 7.736A.834.834 0 017 8a.834.834 0 01-.615-.264L.26 1.514A.861.861 0 010 .889c0-.24.087-.45.26-.625A.834.834 0 01.875 0h12.25c.237 0 .442.088.615.264a.86.86 0 01.26.625z"
fill="#32324D"
fill-rule="evenodd"
/>
</svg>
</span>
</div>
</button>
</div>
</div>
<div
class="c11"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>
`;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import { QueryClientProvider, QueryClient } from 'react-query';
import { axiosInstance } from '../../../../../../core/utils';
import RelationMultiple from '../index';
jest.spyOn(axiosInstance, 'get').mockResolvedValue({
data: {
results: [
{
id: 1,
name: 'Relation entity 1',
},
],
pagination: {
total: 1,
},
},
});
const DEFAULT_PROPS_FIXTURE = {
fieldSchema: {
type: 'relation',
relation: 'manyToMany',
target: 'api::category.category',
},
queryInfos: {
endPoint: 'collection-types/api::address.address',
},
metadatas: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
value: {
count: 1,
},
name: 'categories.name',
rowId: 1,
};
const ComponentFixture = () => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<RelationMultiple {...DEFAULT_PROPS_FIXTURE} />
</IntlProvider>
</ThemeProvider>
</QueryClientProvider>
);
};
describe('DynamicTabe / Cellcontent / RelationMultiple', () => {
it('renders and matches the snapshot', async () => {
const { container } = render(<ComponentFixture />);
expect(container).toMatchSnapshot();
expect(axiosInstance.get).toHaveBeenCalledTimes(0);
});
it('fetches relation entities once the menu is opened', async () => {
const { container } = render(<ComponentFixture />);
const button = container.querySelector('[type=button]');
fireEvent(button, new MouseEvent('mousedown', { bubbles: true }));
expect(screen.getByText('Relations are loading')).toBeInTheDocument();
expect(axiosInstance.get).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByText('Relation entity 1')).toBeInTheDocument());
});
});

View File

@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Typography } from '@strapi/design-system/Typography';
import styled from 'styled-components';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const RelationSingle = ({ metadatas, value }) => {
return (
<TypographyMaxWidth textColor="neutral800" ellipsis>
<CellValue
type={metadatas.mainField.schema.type}
value={value[metadatas.mainField.name] || value.id}
/>
</TypographyMaxWidth>
);
};
RelationSingle.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string.isRequired,
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
}),
}).isRequired,
value: PropTypes.object.isRequired,
};
export default RelationSingle;

View File

@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DynamicTabe / Cellcontent / RelationSingle renders and matches the snapshot 1`] = `
.c2 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.c0 {
color: #32324d;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
line-height: 1.43;
}
.c1 {
max-width: 500px;
}
<div>
<span
class="c0 c1"
>
</span>
<div
class="c2"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>
`;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import RelationSingle from '../index';
const DEFAULT_PROPS_FIXTURE = {
metadatas: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
value: {
count: 1,
},
};
const ComponentFixture = () => {
return (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<RelationSingle {...DEFAULT_PROPS_FIXTURE} />
</IntlProvider>
</ThemeProvider>
);
};
describe('DynamicTabe / Cellcontent / RelationSingle', () => {
it('renders and matches the snapshot', async () => {
const { container } = render(<ComponentFixture />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { useIntl } from 'react-intl';
import { Badge } from '@strapi/design-system/Badge';
import { Box } from '@strapi/design-system/Box';
import { Typography } from '@strapi/design-system/Typography';
import { SimpleMenu, MenuItem } from '@strapi/design-system/SimpleMenu';
import { stopPropagation } from '@strapi/helper-plugin';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 500px;
`;
const RepeatableComponentCell = ({ value, metadatas }) => {
const { formatMessage } = useIntl();
const {
mainField: { type: mainFieldType, name: mainFieldName },
} = metadatas;
const Label = (
<>
<Badge>{value.length}</Badge>{' '}
{formatMessage(
{
id: 'content-manager.containers.ListPage.items',
defaultMessage: '{number, plural, =0 {items} one {item} other {items}}',
},
{ number: value.length }
)}
</>
);
return (
<Box {...stopPropagation}>
<SimpleMenu label={Label} size="S">
{value.map(item => (
<MenuItem key={item.id} aria-disabled>
<TypographyMaxWidth ellipsis>
<CellValue type={mainFieldType} value={item[mainFieldName] || item.id} />
</TypographyMaxWidth>
</MenuItem>
))}
</SimpleMenu>
</Box>
);
};
RepeatableComponentCell.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string,
}),
}).isRequired,
value: PropTypes.array.isRequired,
};
export default RepeatableComponentCell;

View File

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Tooltip } from '@strapi/design-system/Tooltip';
import { Typography } from '@strapi/design-system/Typography';
import CellValue from '../CellValue';
const TypographyMaxWidth = styled(Typography)`
max-width: 250px;
`;
const SingleComponentCell = ({ value, metadatas }) => {
const { mainField } = metadatas;
const content = value[mainField.name];
return (
<Tooltip label={content}>
<TypographyMaxWidth textColor="neutral800" ellipsis>
<CellValue type={mainField.type} value={content} />
</TypographyMaxWidth>
</Tooltip>
);
};
SingleComponentCell.propTypes = {
metadatas: PropTypes.shape({
mainField: PropTypes.shape({
name: PropTypes.string,
type: PropTypes.string,
value: PropTypes.string,
}),
}).isRequired,
value: PropTypes.object.isRequired,
};
export default SingleComponentCell;

View File

@ -4,44 +4,64 @@ import styled from 'styled-components';
import { Typography } from '@strapi/design-system/Typography';
import Media from './Media';
import MultipleMedias from './MultipleMedias';
import Relation from './Relation';
import RelationMultiple from './RelationMultiple';
import RelationSingle from './RelationSingle';
import RepeatableComponent from './RepeatableComponent';
import SingleComponent from './SingleComponent';
import CellValue from './CellValue';
import hasContent from './utils/hasContent';
import isSingleRelation from './utils/isSingleRelation';
const TypographyMaxWidth = styled(Typography)`
max-width: 300px;
`;
const CellContent = ({ content, fieldSchema, metadatas, name, queryInfos, rowId }) => {
if (content === null || content === undefined) {
const { type } = fieldSchema;
if (!hasContent(type, content, metadatas, fieldSchema)) {
return <Typography textColor="neutral800">-</Typography>;
}
if (fieldSchema.type === 'media' && !fieldSchema.multiple) {
return <Media {...content} />;
}
switch (type) {
case 'media':
if (!fieldSchema.multiple) {
return <Media {...content} />;
}
if (fieldSchema.type === 'media' && fieldSchema.multiple) {
return <MultipleMedias value={content} />;
}
return <MultipleMedias value={content} />;
if (fieldSchema.type === 'relation') {
return (
<Relation
fieldSchema={fieldSchema}
queryInfos={queryInfos}
metadatas={metadatas}
value={content}
name={name}
rowId={rowId}
/>
);
}
case 'relation': {
if (isSingleRelation(fieldSchema.relation)) {
return <RelationSingle metadatas={metadatas} value={content} />;
}
return (
<TypographyMaxWidth ellipsis textColor="neutral800">
<CellValue type={fieldSchema.type} value={content} />
</TypographyMaxWidth>
);
return (
<RelationMultiple
fieldSchema={fieldSchema}
queryInfos={queryInfos}
metadatas={metadatas}
value={content}
name={name}
rowId={rowId}
/>
);
}
case 'component':
if (fieldSchema.repeatable === true) {
return <RepeatableComponent value={content} metadatas={metadatas} />;
}
return <SingleComponent value={content} metadatas={metadatas} />;
default:
return (
<TypographyMaxWidth ellipsis textColor="neutral800">
<CellValue type={type} value={content} />
</TypographyMaxWidth>
);
}
};
CellContent.defaultProps = {
@ -51,8 +71,13 @@ CellContent.defaultProps = {
CellContent.propTypes = {
content: PropTypes.any,
fieldSchema: PropTypes.shape({ multiple: PropTypes.bool, type: PropTypes.string.isRequired })
.isRequired,
fieldSchema: PropTypes.shape({
component: PropTypes.string,
multiple: PropTypes.bool,
type: PropTypes.string.isRequired,
repeatable: PropTypes.bool,
relation: PropTypes.string,
}).isRequired,
metadatas: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
rowId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,

View File

@ -0,0 +1,60 @@
import isEmpty from 'lodash/isEmpty';
import isNumber from 'lodash/isNumber';
import isSingleRelation from './isSingleRelation';
import isFieldTypeNumber from '../../../../utils/isFieldTypeNumber';
export default function hasContent(type, content, metadatas, fieldSchema) {
if (type === 'component') {
const {
mainField: { name: mainFieldName, type: mainFieldType },
} = metadatas;
// Repeatable fields show the ID as fallback, in case the mainField
// doesn't have any content
if (fieldSchema?.repeatable) {
return content.length > 0;
}
const value = content?.[mainFieldName];
// relations, media ... show the id as fallback
if (mainFieldName === 'id' && ![undefined, null].includes(value)) {
return true;
}
/* The ID field reports itself as type `integer`, which makes it
impossible to distinguish it from other number fields.
Biginteger fields need to be treated as strings, as `isNumber`
doesn't deal with them.
*/
if (
isFieldTypeNumber(mainFieldType) &&
mainFieldType !== 'biginteger' &&
mainFieldName !== 'id'
) {
return isNumber(value);
}
return !isEmpty(value);
}
if (type === 'relation') {
if (isSingleRelation(fieldSchema.relation)) {
return !isEmpty(content);
}
return content.count > 0;
}
/*
Biginteger fields need to be treated as strings, as `isNumber`
doesn't deal with them.
*/
if (isFieldTypeNumber(type) && type !== 'biginteger') {
return isNumber(content);
}
return !isEmpty(content);
}

View File

@ -0,0 +1,3 @@
export default function isSingleRelation(type) {
return ['oneToOne', 'manyToOne', 'oneToOneMorph'].includes(type);
}

View File

@ -0,0 +1,262 @@
import hasContent from '../hasContent';
describe('hasContent', () => {
describe('number fields', () => {
it('returns true for integer', () => {
const normalizedContent = hasContent('integer', 1);
expect(normalizedContent).toEqual(true);
});
it('returns false for string integer', () => {
const normalizedContent = hasContent('integer', '1');
expect(normalizedContent).toEqual(false);
});
it('returns false for undefined text', () => {
const normalizedContent = hasContent('integer', undefined);
expect(normalizedContent).toEqual(false);
});
it('returns true for float', () => {
const normalizedContent = hasContent('float', 1.111);
expect(normalizedContent).toEqual(true);
});
it('returns true for decimal', () => {
const normalizedContent = hasContent('decimal', 1.111);
expect(normalizedContent).toEqual(true);
});
it('returns true for biginteger', () => {
const normalizedContent = hasContent('biginteger', '12345678901234567890');
expect(normalizedContent).toEqual(true);
});
});
describe('text', () => {
it('returns true for text content', () => {
const normalizedContent = hasContent('text', 'content');
expect(normalizedContent).toEqual(true);
});
it('returns false for empty text content', () => {
const normalizedContent = hasContent('text', '');
expect(normalizedContent).toEqual(false);
});
it('returns false for undefined text content', () => {
const normalizedContent = hasContent('text', undefined);
expect(normalizedContent).toEqual(false);
});
});
describe('ID', () => {
it('returns true for id main fields', () => {
const normalizedContent = hasContent('media', { id: 1 });
expect(normalizedContent).toEqual(true);
});
});
describe('single component', () => {
it('extracts content with content', () => {
const normalizedContent = hasContent(
'component',
{ name: 'content', id: 1 },
{ mainField: { name: 'name' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content without content', () => {
const normalizedContent = hasContent(
'component',
{ name: '', id: 1 },
{ mainField: { name: 'name' } }
);
expect(normalizedContent).toEqual(false);
});
it('extracts integers with content', () => {
const normalizedContent = hasContent(
'component',
{ number: 1, id: 1 },
{ mainField: { name: 'number', type: 'integer' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts integers without content', () => {
const normalizedContent = hasContent(
'component',
{ number: null, id: 1 },
{ mainField: { name: 'number', type: 'integer' } }
);
expect(normalizedContent).toEqual(false);
});
it('extracts float with content', () => {
const normalizedContent = hasContent(
'component',
{ number: 1.11, id: 1 },
{ mainField: { name: 'number', type: 'float' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts float without content', () => {
const normalizedContent = hasContent(
'component',
{ number: null, id: 1 },
{ mainField: { name: 'number', type: 'float' } }
);
expect(normalizedContent).toEqual(false);
});
it('extracts decimal with content', () => {
const normalizedContent = hasContent(
'component',
{ number: 1.11, id: 1 },
{ mainField: { name: 'number', type: 'decimal' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts decimal without content', () => {
const normalizedContent = hasContent(
'component',
{ number: null, id: 1 },
{ mainField: { name: 'number', type: 'decimal' } }
);
expect(normalizedContent).toEqual(false);
});
it('extracts biginteger with content', () => {
const normalizedContent = hasContent(
'component',
{ number: '12345678901234567890', id: 1 },
{ mainField: { name: 'number', type: 'biginteger' } }
);
expect(normalizedContent).toEqual(true);
});
it('extracts biginteger without content', () => {
const normalizedContent = hasContent(
'component',
{ number: null, id: 1 },
{ mainField: { name: 'number', type: 'biginteger' } }
);
expect(normalizedContent).toEqual(false);
});
it('does not fail if the attribute is not set', () => {
const normalizedContent = hasContent(
'component',
{ id: 1 },
{ mainField: { name: 'number', type: 'biginteger' } }
);
expect(normalizedContent).toEqual(false);
});
it('returns true id the main field is an id', () => {
const normalizedContent = hasContent(
'component',
{ id: 1 },
{ mainField: { name: 'id', type: 'integer' } }
);
expect(normalizedContent).toEqual(true);
});
});
describe('repeatable components', () => {
it('extracts content with content', () => {
const normalizedContent = hasContent(
'component',
[{ name: 'content_2', value: 'truthy', id: 1 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content without content', () => {
const normalizedContent = hasContent(
'component',
[{ name: 'content_2', value: '', id: 1 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content without content', () => {
const normalizedContent = hasContent(
'component',
[{ id: 1 }, { id: 2 }],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(true);
});
it('extracts content without content', () => {
const normalizedContent = hasContent(
'component',
[],
{ mainField: { name: 'content_2' } },
{ repeatable: true }
);
expect(normalizedContent).toEqual(false);
});
});
describe('relations', () => {
it('extracts content from multiple relations with content', () => {
const normalizedContent = hasContent('relation', { count: 1 }, undefined, {
relation: 'manyToMany',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from multiple relations without content', () => {
const normalizedContent = hasContent('relation', { count: 0 }, undefined, {
relation: 'manyToMany',
});
expect(normalizedContent).toEqual(false);
});
it('extracts content from single relations with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToOne',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from single relations without content', () => {
const normalizedContent = hasContent('relation', null, undefined, {
relation: 'oneToOne',
});
expect(normalizedContent).toEqual(false);
});
it('returns oneToManyMorph relations as false with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToManyMorph',
});
expect(normalizedContent).toEqual(false);
});
it('extracts content from oneToManyMorph relations with content', () => {
const normalizedContent = hasContent('relation', { id: 1 }, undefined, {
relation: 'oneToOneMorph',
});
expect(normalizedContent).toEqual(true);
});
it('extracts content from oneToManyMorph relations with content', () => {
const normalizedContent = hasContent('relation', null, undefined, {
relation: 'oneToOneMorph',
});
expect(normalizedContent).toEqual(false);
});
});
});

View File

@ -0,0 +1,13 @@
import isSingleRelation from '../isSingleRelation';
describe('isSingleRelation', () => {
['oneToOne', 'manyToOne', 'oneToOneMorph'].forEach(type => {
test(`is single relation: ${type}`, () => {
expect(isSingleRelation(type)).toBeTruthy();
});
});
test('is not single relation', () => {
expect(isSingleRelation('manyToMany')).toBeFalsy();
});
});

View File

@ -148,7 +148,7 @@ const TableRows = ({
onClickDelete(data.id);
}}
label={formatMessage(
{ id: 'app.component.table.delete', defaultMessage: 'Delete {target}' },
{ id: 'global.delete-target', defaultMessage: 'Delete {target}' },
{ target: itemLineText }
)}
noBorder

View File

@ -1,5 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useReducer } from 'react';
import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash';
import isEmpty from 'lodash/isEmpty';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import set from 'lodash/set';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Prompt, Redirect } from 'react-router-dom';
@ -10,10 +14,13 @@ import {
useNotification,
useOverlayBlocker,
useTracking,
getYupInnerErrors,
} from '@strapi/helper-plugin';
import { getTrad, removeKeyInObject } from '../../utils';
import reducer, { initialState } from './reducer';
import { cleanData, createYupSchema, getYupInnerErrors } from './utils';
import { cleanData, createYupSchema } from './utils';
import { getAPIInnerError } from './utils/getAPIInnerError';
const EditViewDataManagerProvider = ({
allLayoutData,
@ -290,30 +297,27 @@ const EditViewDataManagerProvider = ({
e.preventDefault();
let errors = {};
// First validate the form
try {
await yupSchema.validate(modifiedData, { abortEarly: false });
} catch (err) {
errors = getYupInnerErrors(err);
}
const formData = createFormData(modifiedData);
try {
if (isEmpty(errors)) {
const formData = createFormData(modifiedData);
if (isCreatingEntry) {
onPost(formData, trackerProperty);
} else {
onPut(formData, trackerProperty);
if (isCreatingEntry) {
await onPost(formData, trackerProperty);
} else {
await onPut(formData, trackerProperty);
}
}
} catch (err) {
console.log('ValidationError');
console.log(err);
errors = getYupInnerErrors(err);
toggleNotification({
type: 'warning',
message: {
id: getTrad('containers.EditView.notification.errors'),
defaultMessage: 'The form contains some errors',
},
});
errors = {
...errors,
...getAPIInnerError(err),
};
}
dispatch({
@ -321,16 +325,7 @@ const EditViewDataManagerProvider = ({
errors,
});
},
[
createFormData,
isCreatingEntry,
modifiedData,
onPost,
onPut,
toggleNotification,
trackerProperty,
yupSchema,
]
[createFormData, isCreatingEntry, modifiedData, onPost, onPut, trackerProperty, yupSchema]
);
const handlePublish = useCallback(async () => {
@ -345,17 +340,22 @@ const EditViewDataManagerProvider = ({
let errors = {};
try {
// Validate the form using yup
await schema.validate(modifiedData, { abortEarly: false });
onPublish();
} catch (err) {
console.error('ValidationError');
console.error(err);
errors = getYupInnerErrors(err);
}
try {
if (isEmpty(errors)) {
await onPublish();
}
} catch (err) {
errors = {
...errors,
...getAPIInnerError(err),
};
}
dispatch({
type: 'SET_FORM_ERRORS',
errors,

View File

@ -0,0 +1,18 @@
import { getTrad } from '../../../utils';
export function getAPIInnerError(error) {
const errorPayload = error.response.data.error.details.errors;
const validationErrors = errorPayload.reduce((acc, err) => {
acc[err.path.join('.')] = {
id: getTrad(`apiError.${err.message}`),
defaultMessage: err.message,
values: {
field: err.path[err.path.length - 1],
},
};
return acc;
}, {});
return validationErrors;
}

View File

@ -1,17 +0,0 @@
import { get } from 'lodash';
const getYupInnerErrors = error => {
return get(error, 'inner', []).reduce((acc, curr) => {
acc[
curr.path
.split('[')
.join('.')
.split(']')
.join('')
] = { id: curr.message };
return acc;
}, {});
};
export default getYupInnerErrors;

View File

@ -0,0 +1,15 @@
import { getTrad } from '../../../utils';
export function handleAPIError(error) {
const errorPayload = error.response.data.error.details.errors;
const validationErrors = errorPayload.reduce((acc, err) => {
acc[err.path.join('.')] = {
id: getTrad(`apiError.${err.message}`),
defaultMessage: err.message,
};
return acc;
}, {});
return validationErrors;
}

View File

@ -1,4 +1,3 @@
export { default as moveFields } from './moveFields';
export { default as cleanData } from './cleanData';
export { default as getYupInnerErrors } from './getYupInnerErrors';
export { default as createYupSchema } from './schema';

View File

@ -7,6 +7,8 @@ import toNumber from 'lodash/toNumber';
import * as yup from 'yup';
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
import isFieldTypeNumber from '../../../utils/isFieldTypeNumber';
yup.addMethod(yup.mixed, 'defined', function() {
return this.test('defined', errorsTrads.required, value => value !== undefined);
});
@ -240,14 +242,14 @@ const createYupSchemaAttribute = (type, validations, options) => {
.typeError();
}
if (['date', 'datetime'].includes(type)) {
schema = yup.date();
}
if (type === 'biginteger') {
schema = yup.string().matches(/^-?\d*$/);
}
if (['date', 'datetime'].includes(type)) {
schema = yup.date();
}
Object.keys(validations).forEach(validation => {
const validationValue = validations[validation];
@ -273,7 +275,7 @@ const createYupSchemaAttribute = (type, validations, options) => {
return true;
}
if (['number', 'integer', 'biginteger', 'float', 'decimal'].includes(type)) {
if (isFieldTypeNumber(type)) {
if (value === 0) {
return true;
}
@ -344,12 +346,12 @@ const createYupSchemaAttribute = (type, validations, options) => {
}
break;
case 'positive':
if (['number', 'integer', 'bigint', 'float', 'decimal'].includes(type)) {
if (isFieldTypeNumber(type)) {
schema = schema.positive();
}
break;
case 'negative':
if (['number', 'integer', 'bigint', 'float', 'decimal'].includes(type)) {
if (isFieldTypeNumber(type)) {
schema = schema.negative();
}
break;

View File

@ -0,0 +1,36 @@
import { getAPIInnerError } from '../getAPIInnerError';
const API_ERROR_FIXTURE = {
response: {
data: {
error: {
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
describe('getAPIInnerError', () => {
test('transforms API errors into errors, which can be rendered by the CM', () => {
expect(getAPIInnerError(API_ERROR_FIXTURE)).toMatchObject({
'field.0.name': {
id: 'content-manager.apiError.Field contains errors',
},
field: {
id: 'content-manager.apiError.Field must be unique',
},
});
});
});

View File

@ -0,0 +1,36 @@
import { handleAPIError } from '../handleAPIError';
const API_ERROR_FIXTURE = {
response: {
data: {
error: {
details: {
errors: [
{
path: ['field', '0', 'name'],
message: 'Field contains errors',
},
{
path: ['field'],
message: 'Field must be unique',
},
],
},
},
},
},
};
describe('handleAPIError', () => {
test('transforms API errors into errors, which can be rendered by the CM', () => {
expect(handleAPIError(API_ERROR_FIXTURE)).toMatchObject({
'field.0.name': {
id: 'content-manager.apiError.Field contains errors',
},
field: {
id: 'content-manager.apiError.Field must be unique',
},
});
});
});

View File

@ -106,7 +106,6 @@ const FieldComponent = ({
componentValue={componentValue}
componentValueLength={componentValueLength}
componentUid={componentUid}
isNested={isNested}
isReadOnly={isReadOnly}
max={max}
min={min}

View File

@ -82,7 +82,6 @@ const InputUID = ({
onChange({ target: { name, value: data, type: 'text' } }, shouldSetInitialValue);
setIsLoading(false);
} catch (err) {
console.error({ err });
setIsLoading(false);
}
};
@ -107,7 +106,6 @@ const InputUID = ({
setIsLoading(false);
} catch (err) {
console.error({ err });
setIsLoading(false);
}
};
@ -184,12 +182,10 @@ const InputUID = ({
onChange(e);
};
const formattedError = error ? formatMessage({ id: error, defaultMessage: error }) : undefined;
return (
<TextInput
disabled={disabled}
error={formattedError}
error={error}
endAction={
<EndActionWrapper>
{availability && availability.isAvailable && !regenerateLabel && (

View File

@ -42,10 +42,7 @@ function Inputs({
const disabled = useMemo(() => !get(metadatas, 'editable', true), [metadatas]);
const type = fieldSchema.type;
const errorId = useMemo(() => {
return get(formErrors, [keys, 'id'], null);
}, [formErrors, keys]);
const error = get(formErrors, [keys], null);
const fieldName = useMemo(() => {
return getFieldName(keys);
@ -177,7 +174,7 @@ function Inputs({
description={description ? { id: description, defaultMessage: description } : null}
intlLabel={{ id: label, defaultMessage: label }}
labelAction={labelAction}
error={errorId}
error={error && formatMessage(error)}
name={keys}
required={isRequired}
/>
@ -215,6 +212,7 @@ function Inputs({
}
queryInfos={queryInfos}
value={value}
error={error && formatMessage(error)}
/>
);
}
@ -228,7 +226,7 @@ function Inputs({
isNullable={inputType === 'bool' && [null, undefined].includes(fieldSchema.default)}
description={description ? { id: description, defaultMessage: description } : null}
disabled={shouldDisableField}
error={errorId}
error={error}
labelAction={labelAction}
contentTypeUID={currentContentTypeLayout.uid}
customInputs={{

View File

@ -92,7 +92,7 @@ const AccordionGroupCustom = ({ children, footer, label, labelAction, error }) =
{error && (
<Box paddingTop={1}>
<Typography variant="pi" textColor="danger600">
{formatMessage({ id: error.id, defaultMessage: error.id })}
{formatMessage({ id: error.id, defaultMessage: error.id }, { ...error.values })}
</Typography>
</Box>
)}
@ -111,6 +111,8 @@ AccordionGroupCustom.propTypes = {
children: PropTypes.node.isRequired,
error: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
values: PropTypes.object,
}),
footer: PropTypes.node,
label: PropTypes.string,

View File

@ -5,7 +5,6 @@ import { useIntl } from 'react-intl';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import take from 'lodash/take';
import { useNotification } from '@strapi/helper-plugin';
import { Box } from '@strapi/design-system/Box';
import { Flex } from '@strapi/design-system/Flex';
@ -17,6 +16,7 @@ import ItemTypes from '../../utils/ItemTypes';
import ComponentInitializer from '../ComponentInitializer';
import connect from './utils/connect';
import select from './utils/select';
import getComponentErrorKeys from './utils/getComponentErrorKeys';
import DraggedItem from './DraggedItem';
import AccordionGroupCustom from './AccordionGroupCustom';
@ -38,7 +38,6 @@ const RepeatableComponent = ({
componentUid,
componentValue,
componentValueLength,
isNested,
isReadOnly,
max,
min,
@ -59,16 +58,7 @@ const RepeatableComponent = ({
return getMaxTempKey(componentValue || []) + 1;
}, [componentValue]);
const componentErrorKeys = Object.keys(formErrors)
.filter(errorKey => {
return take(errorKey.split('.'), isNested ? 3 : 1).join('.') === name;
})
.map(errorKey => {
return errorKey
.split('.')
.slice(0, name.split('.').length + 1)
.join('.');
});
const componentErrorKeys = getComponentErrorKeys(name, formErrors);
const toggleCollapses = () => {
setCollapseToOpen('');
@ -187,7 +177,6 @@ RepeatableComponent.defaultProps = {
componentValue: null,
componentValueLength: 0,
formErrors: {},
isNested: false,
max: Infinity,
min: 0,
};
@ -198,7 +187,6 @@ RepeatableComponent.propTypes = {
componentValue: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
componentValueLength: PropTypes.number,
formErrors: PropTypes.object,
isNested: PropTypes.bool,
isReadOnly: PropTypes.bool.isRequired,
max: PropTypes.number,
min: PropTypes.number,
@ -207,9 +195,6 @@ RepeatableComponent.propTypes = {
const Memoized = memo(RepeatableComponent);
export default connect(
Memoized,
select
);
export default connect(Memoized, select);
export { RepeatableComponent };

View File

@ -0,0 +1,10 @@
export default function getComponentErrorKeys(name, formErrors) {
return Object.keys(formErrors)
.filter(errorKey => errorKey.startsWith(name))
.map(errorKey =>
errorKey
.split('.')
.slice(0, name.split('.').length + 1)
.join('.')
);
}

View File

@ -0,0 +1,27 @@
import getComponentErrorKeys from '../getComponentErrorKeys';
describe('getComponentErrorKeys', () => {
test('retrieves error keys for non nested components', () => {
const FIXTURE = {
'component.0.name': 'unique-error',
'component.1.field': 'validation-error',
};
expect(getComponentErrorKeys('component', FIXTURE)).toStrictEqual([
'component.0',
'component.1',
]);
});
test('retrieves error keys for nested components', () => {
const FIXTURE = {
'parent.child.0.name': 'unique-error',
'parent.child.1.field': 'validation-error',
};
expect(getComponentErrorKeys('parent.child', FIXTURE)).toStrictEqual([
'parent.child.0',
'parent.child.1',
]);
});
});

View File

@ -70,7 +70,7 @@ function SelectMany({
onMenuOpen={onMenuOpen}
onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={formatMessage(
placeholder || { id: 'components.Select.placeholder', defaultMessage: 'Select...' }
placeholder || { id: 'global.select', defaultMessage: 'Select...' }
)}
styles={styles}
value={[]}

View File

@ -46,7 +46,7 @@ function SelectOne({
onMenuOpen={onMenuOpen}
onMenuScrollToBottom={onMenuScrollToBottom}
placeholder={formatMessage(
placeholder || { id: 'components.Select.placeholder', defaultMessage: 'Select...' }
placeholder || { id: 'global.select', defaultMessage: 'Select...' }
)}
styles={styles}
value={isNull(value) ? null : { label: get(value, [mainField.name], ''), value }}

View File

@ -144,8 +144,6 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
const displayErrors = useCallback(
err => {
const errorPayload = err.response.payload;
console.error(errorPayload);
let errorMessage = get(errorPayload, ['message'], 'Bad Request');
// TODO handle errors correctly when back-end ready
@ -178,10 +176,12 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
} catch (err) {
trackUsageRef.current('didNotDeleteEntry', { error: err, ...trackerProperty });
displayErrors(err);
return Promise.reject(err);
}
},
[slug, toggleNotification, searchToSend]
[slug, displayErrors, toggleNotification, searchToSend]
);
const onDeleteSucceeded = useCallback(() => {
@ -211,12 +211,16 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
setIsCreatingEntry(false);
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
trackUsageRef.current('didNotCreateEntry', { error: err, trackerProperty });
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, dispatch, rawQuery, toggleNotification, setCurrentStep]
@ -239,10 +243,14 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
}, [cleanReceivedData, displayErrors, slug, searchToSend, dispatch, toggleNotification]);
@ -267,12 +275,16 @@ const SingleTypeFormWrapper = ({ allLayoutData, children, slug }) => {
dispatch(submitSucceeded(cleanReceivedData(data)));
dispatch(setStatus('resolved'));
return Promise.resolve(data);
} catch (err) {
displayErrors(err);
trackUsageRef.current('didNotEditEntry', { error: err, trackerProperty });
dispatch(setStatus('resolved'));
return Promise.reject(err);
}
},
[cleanReceivedData, displayErrors, slug, dispatch, rawQuery, toggleNotification]

View File

@ -118,7 +118,6 @@ const Wysiwyg = ({
)
: '';
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
const label = intlLabel.id
? formatMessage(
{ id: intlLabel.id, defaultMessage: intlLabel.defaultMessage },
@ -157,7 +156,7 @@ const Wysiwyg = ({
disabled={disabled}
isExpandMode={isExpandMode}
editorRef={editorRef}
error={errorMessage}
error={error}
isPreviewMode={isPreviewMode}
name={name}
onChange={onChange}
@ -171,10 +170,10 @@ const Wysiwyg = ({
<Hint description={description} name={name} error={error} />
</Stack>
{errorMessage && (
{error && (
<Box paddingTop={1}>
<Typography variant="pi" textColor="danger600" data-strapi-field-error>
{errorMessage}
{error}
</Typography>
</Box>
)}

View File

@ -40,7 +40,7 @@ const formatLayouts = (initialData, models) => {
const formattedCTEditLayout = formatLayoutWithMetas(data.contentType, null, models);
const ctUid = data.contentType.uid;
const formattedEditRelationsLayout = formatEditRelationsLayoutWithMetas(data.contentType, models);
const formattedListLayout = formatListLayoutWithMetas(data.contentType, models);
const formattedListLayout = formatListLayoutWithMetas(data.contentType, data.components);
set(data, ['contentType', 'layouts', 'edit'], formattedCTEditLayout);
set(data, ['contentType', 'layouts', 'editRelations'], formattedEditRelationsLayout);
@ -146,7 +146,7 @@ const formatLayoutWithMetas = (contentTypeConfiguration, ctUid, models) => {
return formatted;
};
const formatListLayoutWithMetas = contentTypeConfiguration => {
const formatListLayoutWithMetas = (contentTypeConfiguration, components) => {
const formatted = contentTypeConfiguration.layouts.list.reduce((acc, current) => {
const fieldSchema = get(contentTypeConfiguration, ['attributes', current], {});
const metadatas = get(contentTypeConfiguration, ['metadatas', current, 'list'], {});
@ -164,6 +164,27 @@ const formatListLayoutWithMetas = contentTypeConfiguration => {
return acc;
}
if (type === 'component') {
const component = components[fieldSchema.component];
const mainFieldName = component.settings.mainField;
const mainFieldAttribute = component.attributes[mainFieldName];
acc.push({
key: `__${current}_key__`,
name: current,
fieldSchema,
metadatas: {
...metadatas,
mainField: {
...mainFieldAttribute,
name: mainFieldName,
},
},
});
return acc;
}
acc.push({ key: `__${current}_key__`, name: current, fieldSchema, metadatas });
return acc;

View File

@ -124,7 +124,10 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
uid: 'compo',
layouts: {
edit: [
[{ name: 'full_name', size: 6 }, { name: 'city', size: 6 }],
[
{ name: 'full_name', size: 6 },
{ name: 'city', size: 6 },
],
[{ name: 'compo', size: 12 }],
],
},
@ -166,7 +169,10 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
editRelations: [],
edit: [
[{ name: 'dz', size: 12 }],
[{ name: 'full_name', size: 6 }, { name: 'city', size: 6 }],
[
{ name: 'full_name', size: 6 },
{ name: 'city', size: 6 },
],
[{ name: 'compo', size: 12 }],
],
},
@ -364,7 +370,10 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
layouts: {
edit: [
[{ name: 'dz', size: 12 }],
[{ name: 'full_name', size: 6 }, { name: 'city', size: 6 }],
[
{ name: 'full_name', size: 6 },
{ name: 'city', size: 6 },
],
[{ name: 'compo', size: 12 }],
],
},
@ -485,12 +494,22 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
const data = {
uid: 'address',
layouts: {
list: ['test', 'categories'],
list: ['test', 'categories', 'component'],
},
metadatas: {
test: {
list: { ok: true },
},
component: {
list: {
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
},
categories: {
list: {
ok: true,
@ -509,6 +528,22 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
type: 'relation',
targetModel: 'category',
},
component: {
type: 'component',
component: 'some.component',
repeatable: false,
},
},
};
const components = {
'some.component': {
settings: {
mainField: 'name',
},
attributes: {
type: 'string',
},
},
};
const expected = [
@ -533,9 +568,23 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
fieldSchema: { type: 'relation', targetModel: 'category' },
queryInfos: { defaultParams: {}, endPoint: 'collection-types/address' },
},
{
name: 'component',
key: '__component_key__',
metadatas: {
mainField: {
name: 'name',
},
},
fieldSchema: {
type: 'component',
component: 'some.component',
repeatable: false,
},
},
];
expect(formatListLayoutWithMetas(data)).toEqual(expected);
expect(formatListLayoutWithMetas(data, components)).toEqual(expected);
});
});
@ -572,7 +621,10 @@ describe('Content Manager | hooks | useFetchContentTypeLayout | utils ', () => {
describe('getDisplayedModels', () => {
it('should return an array containing only the displayable models', () => {
const models = [{ uid: 'test', isDisplayed: false }, { uid: 'testtest', isDisplayed: true }];
const models = [
{ uid: 'test', isDisplayed: false },
{ uid: 'testtest', isDisplayed: true },
];
expect(getDisplayedModels([])).toHaveLength(0);
expect(getDisplayedModels(models)).toHaveLength(1);

View File

@ -8,7 +8,7 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../../components/Theme';
import ThemeToggleProvider from '../../../../components/ThemeToggleProvider';
import { App as ContentManagerApp } from '..';
@ -98,7 +98,7 @@ describe('Content manager | App | main', () => {
const { container } = render(
<IntlProvider messages={{}} defaultLocale="en" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
@ -808,7 +808,7 @@ describe('Content manager | App | main', () => {
render(
<IntlProvider messages={{}} defaultLocale="en" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
@ -854,7 +854,7 @@ describe('Content manager | App | main', () => {
render(
<IntlProvider messages={{}} defaultLocale="en" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>
@ -899,7 +899,7 @@ describe('Content manager | App | main', () => {
render(
<IntlProvider messages={{}} defaultLocale="en" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<DndProvider backend={HTML5Backend}>
<Provider store={store}>

View File

@ -44,7 +44,7 @@ const FieldButtonContent = ({ attribute, onEditField, onDeleteField, children })
<CustomIconButton
label={formatMessage(
{
id: getTrad('app.component.table.delete'),
id: 'global.delete-target',
defaultMessage: `Delete {target}`,
},
{

View File

@ -72,7 +72,7 @@ const FormModal = ({ onToggle, onMetaChange, onSizeChange, onSubmit, type }) =>
}
endActions={
<Button type="submit">
{formatMessage({ id: 'form.button.finish', defaultMessage: 'Finish' })}
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
</Button>
}
/>

View File

@ -11,7 +11,12 @@ import { makeSelectModelAndComponentSchemas } from '../../App/selectors';
import getTrad from '../../../utils/getTrad';
import GenericInput from './GenericInput';
const FIELD_SIZES = [[4, '33%'], [6, '50%'], [8, '66%'], [12, '100%']];
const FIELD_SIZES = [
[4, '33%'],
[6, '50%'],
[8, '66%'],
[12, '100%'],
];
const NON_RESIZABLE_FIELD_TYPES = ['dynamiczone', 'component', 'json', 'richtext'];

View File

@ -59,7 +59,6 @@ const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, upd
'relation',
'component',
'boolean',
'date',
'media',
'richtext',
'timestamp',
@ -250,7 +249,7 @@ const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, upd
to="/"
>
{formatMessage({
id: 'app.components.go-back',
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>
@ -261,7 +260,7 @@ const EditSettingsView = ({ mainLayout, components, isContentTypeView, slug, upd
startIcon={<Check />}
type="submit"
>
{formatMessage({ id: 'form.button.save', defaultMessage: 'Save' })}
{formatMessage({ id: 'global.save', defaultMessage: 'Save' })}
</Button>
}
/>

View File

@ -34,7 +34,12 @@ const makeApp = (history, layout) => {
},
kind: 'collectionType',
layouts: {
edit: [[{ name: 'postal_code', size: 6 }, { name: 'city', size: 6 }]],
edit: [
[
{ name: 'postal_code', size: 6 },
{ name: 'city', size: 6 },
],
],
list: ['postal_code', 'categories'],
editRelations: ['categories'],
},

View File

@ -150,7 +150,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'title', size: 6 }, { name: '_TEMP_', size: 6 }],
rowContent: [
{ name: 'title', size: 6 },
{ name: '_TEMP_', size: 6 },
],
},
],
},
@ -170,7 +173,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'title', size: 8 }, { name: '_TEMP_', size: 4 }],
rowContent: [
{ name: 'title', size: 8 },
{ name: '_TEMP_', size: 4 },
],
},
],
};
@ -186,7 +192,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'title', size: 8 }, { name: 'isActive', size: 4 }],
rowContent: [
{ name: 'title', size: 8 },
{ name: 'isActive', size: 4 },
],
},
],
},
@ -234,7 +243,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
},
{
rowId: 1,
rowContent: [{ name: 'title', size: 6 }, { name: '_TEMP_', size: 6 }],
rowContent: [
{ name: 'title', size: 6 },
{ name: '_TEMP_', size: 6 },
],
},
],
},
@ -358,7 +370,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'isActive', size: 4 }, { name: '_TEMP_', size: 8 }],
rowContent: [
{ name: 'isActive', size: 4 },
{ name: '_TEMP_', size: 8 },
],
},
],
},
@ -404,7 +419,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
},
{
rowId: 1,
rowContent: [{ name: 'slug', size: 6 }, { name: '_TEMP_', size: 6 }],
rowContent: [
{ name: 'slug', size: 6 },
{ name: '_TEMP_', size: 6 },
],
},
],
},
@ -424,7 +442,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
},
{
rowId: 1,
rowContent: [{ name: 'second', size: 4 }, { name: '_TEMP_', size: 8 }],
rowContent: [
{ name: 'second', size: 4 },
{ name: '_TEMP_', size: 8 },
],
},
],
},
@ -455,7 +476,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
},
{
rowId: 1,
rowContent: [{ name: 'slug', size: 6 }, { name: '_TEMP_', size: 6 }],
rowContent: [
{ name: 'slug', size: 6 },
{ name: '_TEMP_', size: 6 },
],
},
],
},
@ -475,7 +499,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
},
{
rowId: 1,
rowContent: [{ name: 'slug', size: 6 }, { name: '_TEMP_', size: 6 }],
rowContent: [
{ name: 'slug', size: 6 },
{ name: '_TEMP_', size: 6 },
],
},
],
},
@ -499,7 +526,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'city', size: 6 }, { name: 'slug', size: 6 }],
rowContent: [
{ name: 'city', size: 6 },
{ name: 'slug', size: 6 },
],
},
{
rowId: 1,
@ -518,7 +548,10 @@ describe('CONTENT MANAGER | CONTAINERS | EditSettingsView | reducer', () => {
edit: [
{
rowId: 0,
rowContent: [{ name: 'city', size: 6 }, { name: 'slug', size: 6 }],
rowContent: [
{ name: 'city', size: 6 },
{ name: 'slug', size: 6 },
],
},
{
rowId: 1,

View File

@ -28,7 +28,13 @@ const formatLayout = arr => {
return acc2;
}, []);
const rowId = acc.length === 0 ? 0 : Math.max.apply(Math, acc.map(o => o.rowId)) + 1;
const rowId =
acc.length === 0
? 0
: Math.max.apply(
Math,
acc.map(o => o.rowId)
) + 1;
const currentRowSize = getRowSize(currentRow);

View File

@ -12,19 +12,31 @@ describe('Content Manager | containers | EditSettingsView | utils | layout', ()
describe('createLayout', () => {
it('should return an array of object with keys rowId and rowContent', () => {
const data = [
[{ name: 'test', size: 4 }, { name: 'test1', size: 4 }],
[
{ name: 'test', size: 4 },
{ name: 'test1', size: 4 },
],
[{ name: 'test2', size: 12 }],
[{ name: 'test3', size: 6 }, { name: 'test4', size: 1 }],
[
{ name: 'test3', size: 6 },
{ name: 'test4', size: 1 },
],
];
const expected = [
{
rowId: 0,
rowContent: [{ name: 'test', size: 4 }, { name: 'test1', size: 4 }],
rowContent: [
{ name: 'test', size: 4 },
{ name: 'test1', size: 4 },
],
},
{ rowId: 1, rowContent: [{ name: 'test2', size: 12 }] },
{
rowId: 2,
rowContent: [{ name: 'test3', size: 6 }, { name: 'test4', size: 1 }],
rowContent: [
{ name: 'test3', size: 6 },
{ name: 'test4', size: 1 },
],
},
];
@ -37,12 +49,18 @@ describe('Content Manager | containers | EditSettingsView | utils | layout', ()
const data = [
{
rowId: 0,
rowContent: [{ name: 'test', size: 4 }, { name: 'test1', size: 4 }],
rowContent: [
{ name: 'test', size: 4 },
{ name: 'test1', size: 4 },
],
},
{ rowId: 1, rowContent: [{ name: 'test2', size: 12 }] },
{
rowId: 2,
rowContent: [{ name: 'test3', size: 6 }, { name: 'test4', size: 1 }],
rowContent: [
{ name: 'test3', size: 6 },
{ name: 'test4', size: 1 },
],
},
];
const expected = [
@ -89,7 +107,10 @@ describe('Content Manager | containers | EditSettingsView | utils | layout', ()
},
{
rowId: 3,
rowContent: [{ name: 'test5', size: 6 }, { name: 'test6', size: 6 }],
rowContent: [
{ name: 'test5', size: 6 },
{ name: 'test6', size: 6 },
],
},
];
@ -238,7 +259,10 @@ describe('Content Manager | containers | EditSettingsView | utils | layout', ()
},
];
const expected = [
[{ name: 'name', size: 6 }, { name: 'test', size: 4 }],
[
{ name: 'name', size: 6 },
{ name: 'test', size: 4 },
],
[{ name: 'name1', size: 4 }],
];

View File

@ -71,7 +71,4 @@ DeleteLink.propTypes = {
const Memoized = memo(DeleteLink, isEqual);
export default connect(
Memoized,
select
);
export default connect(Memoized, select);

View File

@ -88,8 +88,5 @@ DraftAndPublishBadge.propTypes = {
isPublished: PropTypes.bool.isRequired,
};
export default connect(
DraftAndPublishBadge,
select
);
export default connect(DraftAndPublishBadge, select);
export { DraftAndPublishBadge };

View File

@ -172,7 +172,7 @@ const Header = ({
to="/"
>
{formatMessage({
id: 'app.components.HeaderLayout.link.go-back',
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>

View File

@ -8,7 +8,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { MemoryRouter } from 'react-router-dom';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../../../components/Theme';
import ThemeToggleProvider from '../../../../../components/ThemeToggleProvider';
import { Header } from '../index';
@ -33,7 +33,7 @@ const makeApp = (props = defaultProps) => {
return (
<MemoryRouter>
<IntlProvider locale="en" defaultLocale="en" messages={{}}>
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<Header {...props} />
</Theme>

View File

@ -8,7 +8,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../../../components/Theme';
import ThemeToggleProvider from '../../../../../components/ThemeToggleProvider';
import Informations from '../index';
@ -24,7 +24,7 @@ const makeApp = () => {
defaultLocale="en"
messages={{ 'containers.Edit.information': 'Information' }}
>
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<Informations />
</Theme>

View File

@ -113,7 +113,7 @@ const EditFieldForm = ({
}
endActions={
<Button type="submit">
{formatMessage({ id: 'form.button.finish', defaultMessage: 'Finish' })}
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
</Button>
}
/>

View File

@ -191,7 +191,7 @@ const ListSettingsView = ({ layout, slug }) => {
<HeaderLayout
navigationAction={
<Link startIcon={<ArrowLeft />} to={goBackUrl} id="go-back">
{formatMessage({ id: 'app.components.go-back', defaultMessage: 'Back' })}
{formatMessage({ id: 'global.back', defaultMessage: 'Back' })}
</Link>
}
primaryAction={
@ -201,7 +201,7 @@ const ListSettingsView = ({ layout, slug }) => {
disabled={isEqual(modifiedData, initialData)}
type="submit"
>
{formatMessage({ id: 'form.button.save', defaultMessage: 'Save' })}
{formatMessage({ id: 'global.save', defaultMessage: 'Save' })}
</Button>
}
subtitle={formatMessage({

View File

@ -21,11 +21,12 @@ export function resetProps() {
return { type: RESET_PROPS };
}
export const setLayout = contentType => {
export const setLayout = ({ components, contentType }) => {
const { layouts } = contentType;
return {
contentType,
components,
displayedHeaders: layouts.list,
type: SET_LIST_LAYOUT,
};

View File

@ -267,7 +267,7 @@ function ListView({
navigationAction={
<Link startIcon={<ArrowLeft />} to="/content-manager/">
{formatMessage({
id: 'app.components.HeaderLayout.link.go-back',
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>
@ -388,9 +388,6 @@ export function mapDispatchToProps(dispatch) {
dispatch
);
}
const withConnect = connect(
mapStateToProps,
mapDispatchToProps
);
const withConnect = connect(mapStateToProps, mapDispatchToProps);
export default compose(withConnect)(memo(ListView, isEqual));

View File

@ -4,6 +4,7 @@
*/
import produce from 'immer';
import get from 'lodash/get';
import {
GET_DATA,
GET_DATA_SUCCEEDED,
@ -17,6 +18,7 @@ export const initialState = {
data: [],
isLoading: true,
contentType: {},
components: [],
initialDisplayedHeaders: [],
displayedHeaders: [],
pagination: {
@ -26,21 +28,22 @@ export const initialState = {
const listViewReducer = (state = initialState, action) =>
// eslint-disable-next-line consistent-return
produce(state, drafState => {
produce(state, draftState => {
switch (action.type) {
case GET_DATA: {
return {
...initialState,
contentType: state.contentType,
components: state.components,
initialDisplayedHeaders: state.initialDisplayedHeaders,
displayedHeaders: state.displayedHeaders,
};
}
case GET_DATA_SUCCEEDED: {
drafState.pagination = action.pagination;
drafState.data = action.data;
drafState.isLoading = false;
draftState.pagination = action.pagination;
draftState.data = action.data;
draftState.isLoading = false;
break;
}
@ -59,19 +62,49 @@ const listViewReducer = (state = initialState, action) =>
key: `__${name}_key__`,
};
if (attributes[name].type === 'relation') {
drafState.displayedHeaders.push({
...header,
queryInfos: {
defaultParams: {},
endPoint: `collection-types/${uid}`,
},
});
} else {
drafState.displayedHeaders.push(header);
switch (attributes[name].type) {
case 'component': {
const componentName = attributes[name].component;
const mainFieldName = get(
state,
['components', componentName, 'settings', 'mainField'],
null
);
const mainFieldAttribute = get(state, [
'components',
componentName,
'attributes',
mainFieldName,
]);
draftState.displayedHeaders.push({
...header,
metadatas: {
...metas,
mainField: {
...mainFieldAttribute,
name: mainFieldName,
},
},
});
break;
}
case 'relation':
draftState.displayedHeaders.push({
...header,
queryInfos: {
defaultParams: {},
endPoint: `collection-types/${uid}`,
},
});
break;
default:
draftState.displayedHeaders.push(header);
}
} else {
drafState.displayedHeaders = state.displayedHeaders.filter(
draftState.displayedHeaders = state.displayedHeaders.filter(
header => header.name !== name
);
}
@ -79,23 +112,24 @@ const listViewReducer = (state = initialState, action) =>
break;
}
case ON_RESET_LIST_HEADERS: {
drafState.displayedHeaders = state.initialDisplayedHeaders;
draftState.displayedHeaders = state.initialDisplayedHeaders;
break;
}
case RESET_PROPS: {
return initialState;
}
case SET_LIST_LAYOUT: {
const { contentType, displayedHeaders } = action;
const { contentType, components, displayedHeaders } = action;
drafState.contentType = contentType;
drafState.displayedHeaders = displayedHeaders;
drafState.initialDisplayedHeaders = displayedHeaders;
draftState.contentType = contentType;
draftState.components = components;
draftState.displayedHeaders = displayedHeaders;
draftState.initialDisplayedHeaders = displayedHeaders;
break;
}
default:
return drafState;
return draftState;
}
});

View File

@ -15,12 +15,9 @@ const listViewDomain = () => state => state['content-manager_listView'] || initi
*/
const makeSelectListView = () =>
createSelector(
listViewDomain(),
substate => {
return substate;
}
);
createSelector(listViewDomain(), substate => {
return substate;
});
const selectDisplayedHeaders = state => {
const { displayedHeaders } = state['content-manager_listView'];

View File

@ -10,6 +10,7 @@ describe('CONTENT MANAGER | CONTAINERS | ListView | reducer', () => {
state = {
data: [],
isLoading: true,
components: [],
contentType: {},
initialDisplayedHeaders: [],
displayedHeaders: [],

View File

@ -21,7 +21,7 @@ const ListViewLayout = ({ layout, ...props }) => {
}, [rawQuery, replace, redirectionLink]);
useEffect(() => {
dispatch(setLayout(layout.contentType));
dispatch(setLayout(layout));
}, [dispatch, layout]);
useEffect(() => {

View File

@ -3,7 +3,7 @@ import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../../components/Theme';
import ThemeToggleProvider from '../../../../components/ThemeToggleProvider';
import NoContentType from '../index';
@ -19,7 +19,7 @@ describe('CONTENT MANAGER | pages | NoContentType', () => {
} = render(
<Router history={createMemoryHistory()}>
<IntlProvider messages={{}} defaultLocale="en" textComponent="span" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<NoContentType />
</Theme>

View File

@ -7,7 +7,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { lightTheme } from '@strapi/design-system';
import { lightTheme, darkTheme } from '@strapi/design-system';
import Theme from '../../../../components/Theme';
import ThemeToggleProvider from '../../../../components/ThemeToggleProvider';
import NoPermissions from '../index';
@ -23,7 +23,7 @@ describe('<NoPermissions />', () => {
container: { firstChild },
} = render(
<IntlProvider locale="en" messages={{}} defaultLocale="en" textComponent="span">
<ThemeToggleProvider themes={{ light: lightTheme }}>
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<NoPermissions />
</Theme>

View File

@ -140,7 +140,11 @@ const testData = {
id: 1,
name: 'name',
subcomponotrepeatable: { id: 4, name: 'name' },
subrepeatable: [{ id: 1, name: 'name' }, { id: 2, name: 'name' }, { id: 3, name: 'name' }],
subrepeatable: [
{ id: 1, name: 'name' },
{ id: 2, name: 'name' },
{ id: 3, name: 'name' },
],
},
repeatable: [
{

View File

@ -7,7 +7,7 @@ const checkIfAttributeIsDisplayable = attribute => {
return !toLower(attribute.relationType).includes('morph');
}
return !['json', 'component', 'dynamiczone', 'richtext', 'password'].includes(type) && !!type;
return !['json', 'dynamiczone', 'richtext', 'password'].includes(type) && !!type;
};
export default checkIfAttributeIsDisplayable;

View File

@ -0,0 +1,3 @@
export default function isFieldTypeNumber(type) {
return ['integer', 'biginteger', 'decimal', 'float', 'number'].includes(type);
}

View File

@ -0,0 +1,18 @@
import isFieldTypeNumber from '../isFieldTypeNumber';
const FIXTURE = [
['integer', true],
['float', true],
['decimal', true],
['biginteger', true],
['number', true],
['text', false],
];
describe('isFieldTypeNumber', () => {
FIXTURE.forEach(([type, expectation]) => {
test(`${type} is ${expectation}`, () => {
expect(isFieldTypeNumber(type)).toBe(expectation);
});
});
});

View File

@ -10,7 +10,7 @@ const initialState = {
{
icon: Puzzle,
intlLabel: {
id: 'app.components.LeftMenuLinkContainer.listPlugins',
id: 'global.plugins',
defaultMessage: 'Plugins',
},
to: '/list-plugins',
@ -19,7 +19,7 @@ const initialState = {
{
icon: ShoppingCart,
intlLabel: {
id: 'app.components.LeftMenuLinkContainer.installNewPlugin',
id: 'global.marketplace',
defaultMessage: 'Marketplace',
},
to: '/marketplace',
@ -28,7 +28,7 @@ const initialState = {
{
icon: Cog,
intlLabel: {
id: 'app.components.LeftMenuLinkContainer.settings',
id: 'global.settings',
defaultMessage: 'Settings',
},
to: '/settings',

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
/**
* For more details about this hook see:
* https://www.30secondsofcode.org/react/s/use-navigator-on-line
*/
const useNavigatorOnLine = () => {
const onlineStatus =
typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean'
? navigator.onLine
: true;
const [isOnline, setIsOnline] = useState(onlineStatus);
const setOnline = () => setIsOnline(true);
const setOffline = () => setIsOnline(false);
useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline);
return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []);
return isOnline;
};
export default useNavigatorOnLine;

View File

@ -0,0 +1,48 @@
import { renderHook, act } from '@testing-library/react-hooks';
import useNavigatorOnLine from '../index';
describe('useNavigatorOnLine', () => {
it('returns the online state', () => {
jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(true);
const { result } = renderHook(() => useNavigatorOnLine());
expect(result.current).toEqual(true);
});
it('returns the offline state', () => {
jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false);
const { result } = renderHook(() => useNavigatorOnLine());
expect(result.current).toEqual(false);
});
it('listens for network change online', async () => {
// Initialize an offline state
jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false);
const { result, waitForNextUpdate } = renderHook(() => useNavigatorOnLine());
await act(async () => {
// Simulate a change from offline to online
window.dispatchEvent(new window.Event('online'));
await waitForNextUpdate();
});
expect(result.current).toEqual(true);
});
it('listens for network change offline', async () => {
// Initialize an online state
jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(true);
const { result, waitForNextUpdate } = renderHook(() => useNavigatorOnLine());
await act(async () => {
// Simulate a change from online to offline
window.dispatchEvent(new window.Event('offline'));
await waitForNextUpdate();
});
expect(result.current).toEqual(false);
});
});

View File

@ -15,7 +15,7 @@ const useReleaseNotification = () => {
link: {
url: `https://github.com/strapi/strapi/releases/tag/${latestStrapiReleaseTag}`,
label: {
id: 'notification.version.update.link',
id: 'global.see-more',
},
},
blockTransition: true,

View File

@ -25,14 +25,14 @@ const init = (initialState, { settings, shouldUpdateStrapi }) => {
intlLabel: { id: 'Settings.permissions', defaultMessage: 'Administration Panel' },
links: [
{
intlLabel: { id: 'Settings.permissions.menu.link.roles.label', defaultMessage: 'Roles' },
intlLabel: { id: 'global.roles', defaultMessage: 'Roles' },
to: '/settings/roles',
id: 'roles',
isDisplayed: false,
permissions: adminPermissions.settings.roles.main,
},
{
intlLabel: { id: 'Settings.permissions.menu.link.users.label' },
intlLabel: { id: 'global.users' },
// Init the search params directly
to: '/settings/users?pageSize=10&page=1&sort=firstname',
id: 'users',

View File

@ -40,7 +40,7 @@ const UnauthenticatedLayout = ({ children }) => {
<LocaleToggle />
</Box>
</Flex>
<Box paddingTop={11} paddingBottom={11}>
<Box paddingTop={2} paddingBottom={11}>
{children}
</Box>
</div>

View File

@ -75,7 +75,7 @@ const Onboarding = () => {
{
icon: 'book',
label: formatMessage({
id: 'app.components.LeftMenuFooter.documentation',
id: 'global.documentation',
defaultMessage: 'Documentation',
}),
destination: 'https://docs.strapi.io',

View File

@ -107,7 +107,7 @@ const Login = ({ onSubmit, schema, children }) => {
onChange={handleChange}
value={values.password}
label={formatMessage({
id: 'Auth.form.password.label',
id: 'global.password',
defaultMessage: 'Password',
})}
name="password"

View File

@ -33,7 +33,7 @@ describe('ADMIN | PAGES | AUTH | Oops', () => {
}
.c9 {
padding-top: 64px;
padding-top: 8px;
padding-bottom: 64px;
}

View File

@ -143,7 +143,7 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
<Typography as="h1" variant="alpha">
{formatMessage({
id: 'Auth.form.welcome.title',
defaultMessage: 'Welcome!',
defaultMessage: 'Welcome to Strapi!',
})}
</Typography>
</Box>
@ -152,12 +152,12 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
{formatMessage({
id: 'Auth.form.register.subtitle',
defaultMessage:
'Your credentials are only used to authenticate yourself on the admin panel. All saved data will be stored in your own database.',
'Credentials are only used to authenticate in Strapi. All saved data will be stored in your database.',
})}
</Typography>
</CenteredBox>
</Column>
<Stack spacing={7}>
<Stack spacing={6}>
<Grid gap={4}>
<GridItem col={6}>
<TextInput
@ -227,11 +227,11 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
hint={formatMessage({
id: 'Auth.form.password.hint',
defaultMessage:
'Password must contain at least 8 characters, 1 uppercase, 1 lowercase and 1 number',
'Must be at least 8 characters, 1 uppercase, 1 lowercase & 1 number',
})}
required
label={formatMessage({
id: 'Auth.form.password.label',
id: 'global.password',
defaultMessage: 'Password',
})}
type={passwordShown ? 'text' : 'password'}
@ -284,7 +284,7 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
{
id: 'Auth.form.register.news.label',
defaultMessage:
'Keep me updated about the new features and upcoming improvements (by doing this you accept the {terms} and the {policy}).',
'Keep me updated about new features & upcoming improvements (by doing this you accept the {terms} and the {policy}).',
},
{
terms: (

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