mirror of
https://github.com/strapi/strapi.git
synced 2025-07-03 07:05:06 +00:00
Compare commits
26 Commits
v5.17.0-be
...
develop
Author | SHA1 | Date | |
---|---|---|---|
![]() |
74fd566f2a | ||
![]() |
e6db549a1b | ||
![]() |
f146adec87 | ||
![]() |
efb0ce3d8b | ||
![]() |
c550285b00 | ||
![]() |
5f27c76d39 | ||
![]() |
14e074cb0a | ||
![]() |
49f444740d | ||
![]() |
3e666b1ad9 | ||
![]() |
44d3605364 | ||
![]() |
07ae8e84c8 | ||
![]() |
bdbc9ea979 | ||
![]() |
e5c202da01 | ||
![]() |
ed7c7c54ff | ||
![]() |
1aeafdcd36 | ||
![]() |
e3eb76a86a | ||
![]() |
339ea3d197 | ||
![]() |
1fedcce151 | ||
![]() |
d186f1b7f4 | ||
![]() |
be26954af3 | ||
![]() |
0b3ba1bea8 | ||
![]() |
1366892f87 | ||
![]() |
008123965d | ||
![]() |
b46cf06565 | ||
![]() |
f499a6c17f | ||
![]() |
212d172a2f |
2
.github/actions/check-pr-status/package.json
vendored
2
.github/actions/check-pr-status/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "check-pr-status",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
|
92
docs/docs/guides/06-guided-tour.md
Normal file
92
docs/docs/guides/06-guided-tour.md
Normal file
@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Guided Tour
|
||||
---
|
||||
|
||||
This document explains how to create and use Guided Tours in the Strapi CMS.
|
||||
|
||||
## Creating tours
|
||||
|
||||
To create a tour use the `createTour` factory function. The function takes the following arguments:
|
||||
|
||||
- `tourName`: The name of the tour
|
||||
- `steps`: An array of steps
|
||||
|
||||
Each `step` is an object with the following properties:
|
||||
|
||||
- `name`: The name of the step
|
||||
- `requiredActions` (optional): An array of actions that must be completed before the step should be displayed.
|
||||
- `content`: A render prop that receives `Step` and an object with `state` and `dispatch`.
|
||||
|
||||
`Step` has the following composable parts:
|
||||
|
||||
| Component | Description |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Step.Root` | A wrapper of Popover.Content to allow full customization of the popover placement |
|
||||
| `Step.Title` | For simple use cases you can provide the translation props `id` and `defaultMessage`, otherwise completely replace the default implementation by providing `children` |
|
||||
| `Step.Content` | For simple use cases you can provide the translation props `id` and `defaultMessage`, otherwise completely replace the default implementation by providing `children` |
|
||||
| `Step.Actions` | For simple use cases you can specify the `showSkip` and `showStepCount` props, otherwise completely replace the default implementation by providing `children` |
|
||||
|
||||
```tsx
|
||||
const tours = {
|
||||
contentManager: createTour('contentManager', [
|
||||
{
|
||||
name: 'TheFeatureStepName',
|
||||
requiredActions: ['didDoSomethingImportant'],
|
||||
content: (Step) => (
|
||||
<Step.Root side="right">
|
||||
<Step.Title
|
||||
id="tours.contentManager.TheFeatureStepName.title"
|
||||
defaultMessage="The Feature"
|
||||
/>
|
||||
<Step.Content
|
||||
id="tours.contentManager.TheFeatureStepName.content"
|
||||
defaultMessage="This is the content for Step 1 of some feature"
|
||||
/>
|
||||
<Step.Actions showSkip />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
]),
|
||||
} as const;
|
||||
```
|
||||
|
||||
Tours for the CMS are defined in the `packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx` file.
|
||||
|
||||
The tours are then passed to the `UnstableGuidedTourContext` provider.
|
||||
|
||||
```tsx
|
||||
import { tours } from '../UnstableGuidedTour/Tours';
|
||||
import { UnstableGuidedTourContext } from '../UnstableGuidedTour/Context';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<UnstableGuidedTourContext tours={tours}>
|
||||
<Outlet />
|
||||
</UnstableGuidedTourContext>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The provider derives the tour state. Continuing our example from above, the initial tour state would be:
|
||||
|
||||
```ts
|
||||
{
|
||||
contentManager: {
|
||||
currentStep: 0,
|
||||
length: 1,
|
||||
isCompleted: false,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Displaying tours in the CMS
|
||||
|
||||
The tours object is exported from strapi admin and can be accessed anywhere in the CMS. Wrapping an element will anchor the tour tooltip to that element.
|
||||
|
||||
```tsx
|
||||
import { tours } from '../UnstableGuidedTour/Tours';
|
||||
|
||||
<tours.contentManager.TheFeatureStepName>
|
||||
<div>A part of a feature I want to show off<div>
|
||||
</tours.contentManager.TheFeatureStepName>
|
||||
```
|
@ -1,3 +1,5 @@
|
||||
module.exports = ({ env }) => ({
|
||||
future: {},
|
||||
future: {
|
||||
unstableGuidedTour: false,
|
||||
},
|
||||
});
|
||||
|
@ -13,7 +13,7 @@
|
||||
"strapi": "strapi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/plugin-color-picker": "workspace:*",
|
||||
"@strapi/plugin-documentation": "workspace:*",
|
||||
"@strapi/plugin-graphql": "workspace:*",
|
||||
|
@ -0,0 +1,33 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "conditions",
|
||||
"info": {
|
||||
"singularName": "condition",
|
||||
"pluralName": "conditions",
|
||||
"displayName": "Condition"
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
},
|
||||
"pluginOptions": {},
|
||||
"attributes": {
|
||||
"isActive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"country": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::country.country",
|
||||
"conditions": {
|
||||
"visible": {
|
||||
"==": [
|
||||
{
|
||||
"var": "isActive"
|
||||
},
|
||||
true
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* condition controller
|
||||
*/
|
||||
|
||||
const { createCoreController } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreController('api::condition.condition');
|
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* condition router
|
||||
*/
|
||||
|
||||
const { createCoreRouter } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreRouter('api::condition.condition');
|
@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* condition service
|
||||
*/
|
||||
|
||||
const { createCoreService } = require('@strapi/strapi').factories;
|
||||
|
||||
module.exports = createCoreService('api::condition.condition');
|
@ -9,7 +9,6 @@
|
||||
"name": "Country"
|
||||
},
|
||||
"options": {
|
||||
"comment": "",
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {
|
||||
@ -20,24 +19,38 @@
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"minLength": 3,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"minLength": 3,
|
||||
"required": true
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"maxLength": 3,
|
||||
"unique": true,
|
||||
"minLength": 2,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"visible": {
|
||||
"==": [
|
||||
{
|
||||
"var": "visible"
|
||||
},
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
"minLength": 2,
|
||||
"maxLength": 3,
|
||||
"required": true,
|
||||
"unique": true
|
||||
},
|
||||
"visible": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,8 @@
|
||||
"info": {
|
||||
"displayName": "Homepage",
|
||||
"singularName": "homepage",
|
||||
"pluralName": "homepages"
|
||||
"pluralName": "homepages",
|
||||
"description": ""
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true
|
||||
@ -34,16 +35,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mediaType": {
|
||||
"type": "enumeration",
|
||||
"enum": ["multiple", "single"],
|
||||
"default": "multiple",
|
||||
"required": true
|
||||
},
|
||||
"single": {
|
||||
"type": "media",
|
||||
"allowedTypes": ["images", "files", "videos"],
|
||||
"required": false
|
||||
"multiple": false,
|
||||
"required": false,
|
||||
"conditions": {
|
||||
"visible": {
|
||||
"==": [
|
||||
{
|
||||
"var": "mediaType"
|
||||
},
|
||||
"single"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allowedTypes": ["images", "files", "videos"]
|
||||
},
|
||||
"multiple": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"allowedTypes": ["images", "videos"],
|
||||
"required": false
|
||||
"required": false,
|
||||
"conditions": {
|
||||
"visible": {
|
||||
"==": [
|
||||
{
|
||||
"var": "mediaType"
|
||||
},
|
||||
"multiple"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allowedTypes": ["images", "videos"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,8 +27,8 @@
|
||||
"@strapi/strapi": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"eslint": "8.50.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/admin-test-utils",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"private": true,
|
||||
"description": "Test utilities for the Strapi administration panel",
|
||||
"license": "MIT",
|
||||
@ -84,10 +84,10 @@
|
||||
"devDependencies": {
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@testing-library/jest-dom": "6.4.5",
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"jest-environment-jsdom": "29.6.1",
|
||||
"styled-components": "6.1.8",
|
||||
"tsconfig": "5.16.0"
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/cloud-cli",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Commands to interact with the Strapi Cloud",
|
||||
"keywords": [
|
||||
"strapi",
|
||||
@ -47,7 +47,7 @@
|
||||
"watch": "run -T rollup -c -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"axios": "1.8.4",
|
||||
"boxen": "5.1.2",
|
||||
"chalk": "4.1.2",
|
||||
@ -72,8 +72,8 @@
|
||||
"@types/cli-progress": "3.11.5",
|
||||
"@types/eventsource": "1.1.15",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"tsconfig": "5.16.0"
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-strapi-app",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Generate a new Strapi application.",
|
||||
"keywords": [
|
||||
"create-strapi-app",
|
||||
@ -50,7 +50,7 @@
|
||||
"watch": "run -T rollup -c -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/cloud-cli": "5.16.0",
|
||||
"@strapi/cloud-cli": "5.17.0",
|
||||
"async-retry": "1.3.3",
|
||||
"chalk": "4.1.2",
|
||||
"commander": "8.3.0",
|
||||
@ -69,8 +69,8 @@
|
||||
"@types/async-retry": "^1",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/inquirer": "8.2.5",
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"tsconfig": "5.16.0"
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-strapi",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Generate a new Strapi application.",
|
||||
"keywords": [
|
||||
"create-strapi",
|
||||
@ -36,7 +36,7 @@
|
||||
"bin/"
|
||||
],
|
||||
"dependencies": {
|
||||
"create-strapi-app": "5.16.0"
|
||||
"create-strapi-app": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { darkTheme, lightTheme } from '@strapi/design-system';
|
||||
import { User } from '@strapi/icons';
|
||||
import invariant from 'invariant';
|
||||
import isFunction from 'lodash/isFunction';
|
||||
import merge from 'lodash/merge';
|
||||
@ -317,6 +318,29 @@ class StrapiApp {
|
||||
getPlugin = (pluginId: PluginConfig['id']) => this.plugins[pluginId];
|
||||
|
||||
async register(customRegister?: unknown) {
|
||||
this.widgets.register([
|
||||
{
|
||||
icon: User,
|
||||
title: {
|
||||
id: 'widget.profile.title',
|
||||
defaultMessage: 'Profile',
|
||||
},
|
||||
component: async () => {
|
||||
const { ProfileWidget } = await import('./components/Widgets');
|
||||
return ProfileWidget;
|
||||
},
|
||||
pluginId: 'admin',
|
||||
id: 'profile-info',
|
||||
link: {
|
||||
label: {
|
||||
id: 'global.profile.settings',
|
||||
defaultMessage: 'Profile settings',
|
||||
},
|
||||
href: '/me',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Object.keys(this.appPlugins).forEach((plugin) => {
|
||||
this.appPlugins[plugin].register(this);
|
||||
});
|
||||
|
@ -9,7 +9,7 @@ import { styled } from 'styled-components';
|
||||
import { useAuth } from '../features/Auth';
|
||||
import { useTracking } from '../features/Tracking';
|
||||
import { Menu, MenuItem } from '../hooks/useMenu';
|
||||
import { getDisplayName } from '../utils/users';
|
||||
import { getDisplayName, getInitials } from '../utils/users';
|
||||
|
||||
import { MainNav } from './MainNav/MainNav';
|
||||
import { NavBrand } from './MainNav/NavBrand';
|
||||
@ -57,11 +57,7 @@ const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }: LeftMenuProps) =
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const initials = userDisplayName
|
||||
.split(' ')
|
||||
.map((name) => name.substring(0, 1))
|
||||
.join('')
|
||||
.substring(0, 2);
|
||||
const initials = getInitials(user);
|
||||
|
||||
const handleClickOnLink = (destination: string) => {
|
||||
trackUsage('willNavigate', { from: pathname, to: destination });
|
||||
|
@ -7,9 +7,11 @@ import {
|
||||
BadgeProps,
|
||||
AccessibleIcon,
|
||||
} from '@strapi/design-system';
|
||||
import { NavLink as RouterLink, LinkProps } from 'react-router-dom';
|
||||
import { NavLink as RouterLink, LinkProps, To } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { tours as unstable_tours } from '../UnstableGuidedTour/Tours';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Link
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
@ -39,8 +41,25 @@ const MainNavLinkWrapper = styled(RouterLink)`
|
||||
}
|
||||
`;
|
||||
|
||||
const getGuidedTourTooltip = (to: To) => {
|
||||
const normalizedTo = to.toString().replace(/\//g, '');
|
||||
|
||||
switch (normalizedTo) {
|
||||
case 'content-manager':
|
||||
return unstable_tours.contentTypeBuilder.Finish;
|
||||
default:
|
||||
return React.Fragment;
|
||||
}
|
||||
};
|
||||
|
||||
const LinkImpl = ({ children, ...props }: LinkProps) => {
|
||||
return <MainNavLinkWrapper {...props}>{children}</MainNavLinkWrapper>;
|
||||
const GuidedTourTooltip = getGuidedTourTooltip(props.to);
|
||||
|
||||
return (
|
||||
<GuidedTourTooltip>
|
||||
<MainNavLinkWrapper {...props}>{children}</MainNavLinkWrapper>
|
||||
</GuidedTourTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
|
@ -92,7 +92,7 @@ export const NavUser = ({ children, initials, ...props }: NavUserProps) => {
|
||||
|
||||
<Menu.Item onSelect={handleProfile}>
|
||||
{formatMessage({
|
||||
id: 'global.profile',
|
||||
id: 'global.profile.settings',
|
||||
defaultMessage: 'Profile settings',
|
||||
})}
|
||||
</Menu.Item>
|
||||
|
@ -13,6 +13,7 @@ import { TrackingProvider } from '../features/Tracking';
|
||||
import { GuidedTourProvider } from './GuidedTour/Provider';
|
||||
import { LanguageProvider } from './LanguageProvider';
|
||||
import { Theme } from './Theme';
|
||||
import { UnstableGuidedTourContext } from './UnstableGuidedTour/Context';
|
||||
|
||||
import type { Store } from '../core/store/configure';
|
||||
import type { StrapiApp } from '../StrapiApp';
|
||||
@ -57,13 +58,15 @@ const Providers = ({ children, strapi, store }: ProvidersProps) => {
|
||||
<NotificationsProvider>
|
||||
<TrackingProvider>
|
||||
<GuidedTourProvider>
|
||||
<ConfigurationProvider
|
||||
defaultAuthLogo={strapi.configurations.authLogo}
|
||||
defaultMenuLogo={strapi.configurations.menuLogo}
|
||||
showReleaseNotification={strapi.configurations.notifications.releases}
|
||||
>
|
||||
{children}
|
||||
</ConfigurationProvider>
|
||||
<UnstableGuidedTourContext>
|
||||
<ConfigurationProvider
|
||||
defaultAuthLogo={strapi.configurations.authLogo}
|
||||
defaultMenuLogo={strapi.configurations.menuLogo}
|
||||
showReleaseNotification={strapi.configurations.notifications.releases}
|
||||
>
|
||||
{children}
|
||||
</ConfigurationProvider>
|
||||
</UnstableGuidedTourContext>
|
||||
</GuidedTourProvider>
|
||||
</TrackingProvider>
|
||||
</NotificationsProvider>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useId, useState, Fragment } from 'react';
|
||||
|
||||
import { Box, SubNav as DSSubNav, Flex, Typography, IconButton } from '@strapi/design-system';
|
||||
import { ChevronDown, Plus } from '@strapi/icons';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { tours as unstable_tours } from './UnstableGuidedTour/Tours';
|
||||
|
||||
const Main = styled(DSSubNav)`
|
||||
background-color: ${({ theme }) => theme.colors.neutral0};
|
||||
border-right: 1px solid ${({ theme }) => theme.colors.neutral150};
|
||||
@ -113,6 +115,24 @@ const Sections = ({ children, ...props }: { children: React.ReactNode[]; [key: s
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* This would be better in the content-type-builder package directly but currently
|
||||
* the SubNav API does not expose a way to wrap the link, instead it wraps the link and the list
|
||||
*/
|
||||
const getGuidedTourTooltip = (sectionName: string) => {
|
||||
switch (sectionName) {
|
||||
case 'Collection Types':
|
||||
return unstable_tours.contentTypeBuilder.CollectionTypes;
|
||||
case 'Single Types':
|
||||
return unstable_tours.contentTypeBuilder.SingleTypes;
|
||||
case 'Components':
|
||||
return unstable_tours.contentTypeBuilder.Components;
|
||||
default:
|
||||
return Fragment;
|
||||
}
|
||||
};
|
||||
|
||||
const Section = ({
|
||||
label,
|
||||
children,
|
||||
@ -123,6 +143,7 @@ const Section = ({
|
||||
link?: { label: string; onClik: () => void };
|
||||
}) => {
|
||||
const listId = useId();
|
||||
const GuidedTourTooltip = getGuidedTourTooltip(label);
|
||||
|
||||
return (
|
||||
<Flex direction="column" alignItems="stretch" gap={2}>
|
||||
@ -136,15 +157,17 @@ const Section = ({
|
||||
</Box>
|
||||
</Flex>
|
||||
{link && (
|
||||
<IconButton
|
||||
label={link.label}
|
||||
variant="ghost"
|
||||
withTooltip
|
||||
onClick={link.onClik}
|
||||
size="XS"
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
<GuidedTourTooltip>
|
||||
<IconButton
|
||||
label={link.label}
|
||||
variant="ghost"
|
||||
withTooltip
|
||||
onClick={link.onClik}
|
||||
size="XS"
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</GuidedTourTooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { produce } from 'immer';
|
||||
|
||||
import { createContext } from '../Context';
|
||||
|
||||
import { type Tours, tours as guidedTours } from './Tours';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* GuidedTourProvider
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
type ValidTourName = keyof Tours;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'next_step';
|
||||
payload: ValidTourName;
|
||||
}
|
||||
| {
|
||||
type: 'skip_tour';
|
||||
payload: ValidTourName;
|
||||
};
|
||||
|
||||
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
|
||||
type State = {
|
||||
tours: Tour;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}>('UnstableGuidedTour');
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
return produce(state, (draft) => {
|
||||
if (action.type === 'next_step') {
|
||||
const nextStep = draft.tours[action.payload].currentStep + 1;
|
||||
draft.tours[action.payload].currentStep = nextStep;
|
||||
draft.tours[action.payload].isCompleted = nextStep === draft.tours[action.payload].length;
|
||||
}
|
||||
|
||||
if (action.type === 'skip_tour') {
|
||||
draft.tours[action.payload].isCompleted = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const UnstableGuidedTourContext = ({
|
||||
children,
|
||||
enabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const stored = getTourStateFromLocalStorage();
|
||||
const initialState = stored
|
||||
? stored
|
||||
: {
|
||||
tours: Object.keys(guidedTours).reduce((acc, tourName) => {
|
||||
const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length;
|
||||
acc[tourName as ValidTourName] = {
|
||||
currentStep: 0,
|
||||
length: tourLength,
|
||||
isCompleted: false,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Tour),
|
||||
};
|
||||
|
||||
const [state, dispatch] = React.useReducer(reducer, { ...initialState, enabled });
|
||||
|
||||
// Sync local storage
|
||||
React.useEffect(() => {
|
||||
if (window.strapi.future.isEnabled('unstableGuidedTour')) {
|
||||
saveTourStateToLocalStorage(state);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
|
||||
{children}
|
||||
</GuidedTourProviderImpl>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Local Storage
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const STORAGE_KEY = 'STRAPI_GUIDED_TOUR';
|
||||
function getTourStateFromLocalStorage(): State | null {
|
||||
const tourState = localStorage.getItem(STORAGE_KEY);
|
||||
return tourState ? JSON.parse(tourState) : null;
|
||||
}
|
||||
|
||||
function saveTourStateToLocalStorage(state: State) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export type { Action, State, ValidTourName };
|
||||
export { UnstableGuidedTourContext, unstableUseGuidedTour, reducer };
|
@ -0,0 +1,142 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Popover, Box, Flex, Button, Typography, LinkButton } from '@strapi/design-system';
|
||||
import { FormattedMessage, type MessageDescriptor } from 'react-intl';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { unstableUseGuidedTour, ValidTourName } from './Context';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Step factory
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
type WithChildren = {
|
||||
children: React.ReactNode;
|
||||
id?: never;
|
||||
defaultMessage?: never;
|
||||
};
|
||||
|
||||
type WithIntl = {
|
||||
children?: undefined;
|
||||
id: MessageDescriptor['id'];
|
||||
defaultMessage: MessageDescriptor['defaultMessage'];
|
||||
};
|
||||
|
||||
type WithActionsChildren = {
|
||||
children: React.ReactNode;
|
||||
showStepCount?: boolean;
|
||||
showSkip?: boolean;
|
||||
};
|
||||
|
||||
type WithActionsProps = {
|
||||
children?: undefined;
|
||||
showStepCount?: boolean;
|
||||
showSkip?: boolean;
|
||||
};
|
||||
|
||||
type StepProps = WithChildren | WithIntl;
|
||||
type ActionsProps = WithActionsChildren | WithActionsProps;
|
||||
|
||||
type Step = {
|
||||
Root: React.ForwardRefExoticComponent<React.ComponentProps<typeof Popover.Content>>;
|
||||
Title: (props: StepProps) => React.ReactNode;
|
||||
Content: (props: StepProps) => React.ReactNode;
|
||||
Actions: (props: ActionsProps & { to?: string }) => React.ReactNode;
|
||||
};
|
||||
|
||||
const ActionsContainer = styled(Flex)`
|
||||
border-top: ${({ theme }) => `1px solid ${theme.colors.neutral150}`};
|
||||
`;
|
||||
|
||||
const createStepComponents = (tourName: ValidTourName): Step => ({
|
||||
Root: React.forwardRef((props, ref) => (
|
||||
<Popover.Content ref={ref} side="top" align="center" style={{ border: 'none' }} {...props}>
|
||||
<Flex width="360px" direction="column" alignItems="start">
|
||||
{props.children}
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
)),
|
||||
|
||||
Title: (props) => {
|
||||
return (
|
||||
<Box paddingTop={5} paddingLeft={5} paddingRight={5} paddingBottom={1} width="100%">
|
||||
{'children' in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<Typography tag="div" variant="omega" fontWeight="bold">
|
||||
<FormattedMessage tagName="h1" id={props.id} defaultMessage={props.defaultMessage} />
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
|
||||
Content: (props) => (
|
||||
<Box paddingBottom={5} paddingLeft={5} paddingRight={5} width="100%">
|
||||
{'children' in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<Typography tag="div" variant="omega">
|
||||
<FormattedMessage tagName="p" id={props.id} defaultMessage={props.defaultMessage} />
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
|
||||
Actions: ({ showStepCount = true, showSkip = false, to, ...props }) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = unstableUseGuidedTour('GuidedTourPopover', (s) => s.dispatch);
|
||||
const state = unstableUseGuidedTour('GuidedTourPopover', (s) => s.state);
|
||||
const currentStep = state.tours[tourName].currentStep + 1;
|
||||
// TODO: Currently all tours do not count their last step, but we should find a way to make this more smart
|
||||
const displayedLength = state.tours[tourName].length - 1;
|
||||
|
||||
return (
|
||||
<ActionsContainer width="100%" padding={3} paddingLeft={5}>
|
||||
{'children' in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<Flex flex={1} justifyContent={showStepCount ? 'space-between' : 'flex-end'}>
|
||||
{showStepCount && (
|
||||
<Typography variant="omega" fontSize="12px">
|
||||
<FormattedMessage
|
||||
id="tours.stepCount"
|
||||
defaultMessage="Step {currentStep} of {tourLength}"
|
||||
values={{ currentStep, tourLength: displayedLength }}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
<Flex gap={2}>
|
||||
{showSkip && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => dispatch({ type: 'skip_tour', payload: tourName })}
|
||||
>
|
||||
<FormattedMessage id="tours.skip" defaultMessage="Skip" />
|
||||
</Button>
|
||||
)}
|
||||
{to ? (
|
||||
<LinkButton
|
||||
onClick={() => {
|
||||
dispatch({ type: 'next_step', payload: tourName });
|
||||
navigate(to);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="tours.next" defaultMessage="Next" />
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button onClick={() => dispatch({ type: 'next_step', payload: tourName })}>
|
||||
<FormattedMessage id="tours.next" defaultMessage="Next" />
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export type { Step };
|
||||
export { createStepComponents };
|
@ -0,0 +1,238 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Popover, Portal } from '@strapi/design-system';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { type GetGuidedTourMeta } from '../../../../shared/contracts/admin';
|
||||
import { useGetGuidedTourMetaQuery } from '../../services/admin';
|
||||
|
||||
import { type State, type Action, unstableUseGuidedTour, ValidTourName } from './Context';
|
||||
import { Step, createStepComponents } from './Step';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Tours
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const tours = {
|
||||
contentTypeBuilder: createTour('contentTypeBuilder', [
|
||||
{
|
||||
name: 'Introduction',
|
||||
content: (Step) => (
|
||||
<Step.Root side="bottom">
|
||||
<Step.Title
|
||||
id="tours.contentTypeBuilder.Introduction.title"
|
||||
defaultMessage="Content-Type Builder"
|
||||
/>
|
||||
<Step.Content
|
||||
id="tours.contentTypeBuilder.Introduction.content"
|
||||
defaultMessage="Create and manage your content structure with collection types, single types and components."
|
||||
/>
|
||||
<Step.Actions showSkip />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'CollectionTypes',
|
||||
content: (Step) => (
|
||||
<Step.Root side="right" sideOffset={26}>
|
||||
<Step.Title
|
||||
id="tours.contentTypeBuilder.CollectionTypes.title"
|
||||
defaultMessage="Collection Types"
|
||||
/>
|
||||
<Step.Content
|
||||
id="tours.contentTypeBuilder.CollectionTypes.content"
|
||||
defaultMessage="Create and manage your content structure with collection types, single types and components."
|
||||
/>
|
||||
<Step.Actions />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'SingleTypes',
|
||||
content: (Step) => (
|
||||
<Step.Root side="right" sideOffset={26}>
|
||||
<Step.Title
|
||||
id="tours.contentTypeBuilder.SingleTypes.title"
|
||||
defaultMessage="Single Types"
|
||||
/>
|
||||
<Step.Content
|
||||
id="tours.contentTypeBuilder.SingleTypes.content"
|
||||
defaultMessage="A content structure that can manage a single entry, such as a homepage or a header."
|
||||
/>
|
||||
<Step.Actions />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Components',
|
||||
content: (Step) => (
|
||||
<Step.Root side="right" sideOffset={26}>
|
||||
<Step.Title id="tours.contentTypeBuilder.Components.title" defaultMessage="Components" />
|
||||
<Step.Content
|
||||
id="tours.contentTypeBuilder.Components.content"
|
||||
defaultMessage="A reusable content structure that can be used across multiple content types, such as buttons, sliders or cards."
|
||||
/>
|
||||
<Step.Actions />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Finish',
|
||||
content: (Step) => (
|
||||
<Step.Root side="right" sideOffset={32}>
|
||||
<Step.Title
|
||||
id="tours.contentTypeBuilder.Finish.title"
|
||||
defaultMessage="It’s time to create content!"
|
||||
/>
|
||||
<Step.Content
|
||||
id="tours.contentTypeBuilder.Finish.content"
|
||||
defaultMessage="Now that you created content types, you’ll be able to create content in the content manager."
|
||||
/>
|
||||
<Step.Actions showStepCount={false} to="/content-manager" />
|
||||
</Step.Root>
|
||||
),
|
||||
when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'),
|
||||
},
|
||||
]),
|
||||
} as const;
|
||||
|
||||
type Tours = typeof tours;
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* GuidedTourTooltip
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
type Content = (
|
||||
Step: Step,
|
||||
{
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
|
||||
type GuidedTourTooltipProps = {
|
||||
children: React.ReactNode;
|
||||
content: Content;
|
||||
tourName: ValidTourName;
|
||||
step: number;
|
||||
when?: (completedActions: GetGuidedTourMeta.Response['data']['completedActions']) => boolean;
|
||||
};
|
||||
|
||||
const UnstableGuidedTourTooltip = ({ children, ...props }: GuidedTourTooltipProps) => {
|
||||
const state = unstableUseGuidedTour('TooltipWrapper', (s) => s.state);
|
||||
const hasFutureFlag = window.strapi.future.isEnabled('unstableGuidedTour');
|
||||
|
||||
if (!state.enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!hasFutureFlag) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <GuidedTourTooltipImpl {...props}>{children}</GuidedTourTooltipImpl>;
|
||||
};
|
||||
|
||||
const GuidedTourOverlay = styled(Box)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(50, 50, 77, 0.2);
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
const GuidedTourTooltipImpl = ({
|
||||
children,
|
||||
content,
|
||||
tourName,
|
||||
step,
|
||||
when,
|
||||
}: GuidedTourTooltipProps) => {
|
||||
const { data: guidedTourMeta } = useGetGuidedTourMetaQuery();
|
||||
|
||||
const state = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.state);
|
||||
const dispatch = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.dispatch);
|
||||
|
||||
const isCurrentStep = state.tours[tourName].currentStep === step;
|
||||
const isStepConditionMet = when ? when(guidedTourMeta?.data?.completedActions ?? []) : true;
|
||||
const isPopoverOpen =
|
||||
guidedTourMeta?.data?.isFirstSuperAdminUser &&
|
||||
!state.tours[tourName].isCompleted &&
|
||||
isCurrentStep &&
|
||||
isStepConditionMet;
|
||||
|
||||
// Lock the scroll
|
||||
React.useEffect(() => {
|
||||
if (!isPopoverOpen) return;
|
||||
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
};
|
||||
}, [isPopoverOpen]);
|
||||
|
||||
const Step = React.useMemo(() => createStepComponents(tourName), [tourName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPopoverOpen && (
|
||||
<Portal>
|
||||
<GuidedTourOverlay />
|
||||
</Portal>
|
||||
)}
|
||||
<Popover.Root open={isPopoverOpen}>
|
||||
<Popover.Anchor>{children}</Popover.Anchor>
|
||||
{content(Step, { state, dispatch })}
|
||||
</Popover.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Tour factory
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
type TourStep<P extends string> = {
|
||||
name: P;
|
||||
content: Content;
|
||||
when?: (completedActions: GetGuidedTourMeta.Response['data']['completedActions']) => boolean;
|
||||
};
|
||||
|
||||
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
|
||||
type Components = {
|
||||
[K in T[number]['name']]: React.ComponentType<{ children: React.ReactNode }>;
|
||||
};
|
||||
|
||||
const tour = steps.reduce((acc, step, index) => {
|
||||
if (step.name in acc) {
|
||||
throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`);
|
||||
}
|
||||
|
||||
acc[step.name as keyof Components] = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<UnstableGuidedTourTooltip
|
||||
tourName={tourName as ValidTourName}
|
||||
step={index}
|
||||
content={step.content}
|
||||
when={step.when}
|
||||
>
|
||||
{children}
|
||||
</UnstableGuidedTourTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as Components);
|
||||
|
||||
return tour;
|
||||
}
|
||||
|
||||
export type { Content, Tours };
|
||||
export { tours };
|
@ -0,0 +1,172 @@
|
||||
import { type Action, reducer } from '../Context';
|
||||
|
||||
describe('GuidedTour | reducer', () => {
|
||||
describe('next_step', () => {
|
||||
it('should increment the step count for the specified tour', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: false,
|
||||
length: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
payload: 'contentTypeBuilder',
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 1,
|
||||
isCompleted: false,
|
||||
length: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should preserve other tour states when advancing a specific tour', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 1,
|
||||
isCompleted: false,
|
||||
length: 1,
|
||||
},
|
||||
contentManager: {
|
||||
currentStep: 2,
|
||||
isCompleted: false,
|
||||
length: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
payload: 'contentTypeBuilder',
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 2,
|
||||
isCompleted: false,
|
||||
length: 1,
|
||||
},
|
||||
contentManager: {
|
||||
currentStep: 2,
|
||||
isCompleted: false,
|
||||
length: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should mark tour as completed when reaching the last step', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: false,
|
||||
length: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
payload: 'contentTypeBuilder',
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 1,
|
||||
isCompleted: true,
|
||||
length: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skip_tour', () => {
|
||||
it('should mark the tour as completed', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: false,
|
||||
length: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action: Action = {
|
||||
type: 'skip_tour',
|
||||
payload: 'contentTypeBuilder',
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: true,
|
||||
length: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should preserve other tour states when skipping a specific tour', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: false,
|
||||
length: 3,
|
||||
},
|
||||
contentManager: {
|
||||
currentStep: 1,
|
||||
isCompleted: false,
|
||||
length: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action: Action = {
|
||||
type: 'skip_tour',
|
||||
payload: 'contentTypeBuilder',
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
tours: {
|
||||
contentTypeBuilder: {
|
||||
currentStep: 0,
|
||||
isCompleted: true,
|
||||
length: 3,
|
||||
},
|
||||
contentManager: {
|
||||
currentStep: 1,
|
||||
isCompleted: false,
|
||||
length: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
});
|
42
packages/core/admin/admin/src/components/Widgets.tsx
Normal file
42
packages/core/admin/admin/src/components/Widgets.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useAuth } from '@strapi/admin/strapi-admin';
|
||||
import { Avatar, Badge, Flex, Typography } from '@strapi/design-system';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { getDisplayName, getInitials } from '../utils/users';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* ProfileWidget
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const DisplayNameTypography = styled(Typography)`
|
||||
font-size: 2.4rem;
|
||||
`;
|
||||
|
||||
const ProfileWidget = () => {
|
||||
const user = useAuth('User', (state) => state.user);
|
||||
const userDisplayName = getDisplayName(user);
|
||||
const initials = getInitials(user);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={3} height="100%" justifyContent="center">
|
||||
<Avatar.Item delayMs={0} fallback={initials} />
|
||||
{userDisplayName && (
|
||||
<DisplayNameTypography fontWeight="bold" textTransform="none">
|
||||
{userDisplayName}
|
||||
</DisplayNameTypography>
|
||||
)}
|
||||
{user?.email && (
|
||||
<Typography variant="omega" textColor="neutral600">
|
||||
{user?.email}
|
||||
</Typography>
|
||||
)}
|
||||
{user?.roles?.length && (
|
||||
<Flex marginTop={2} gap={1} wrap="wrap">
|
||||
{user?.roles?.map((role) => <Badge key={role.id}>{role.name}</Badge>)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileWidget };
|
@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@tests/utils';
|
||||
|
||||
import { ProfileWidget } from '../Widgets';
|
||||
|
||||
// Mock the useAuth hook
|
||||
jest.mock('@strapi/admin/strapi-admin', () => ({
|
||||
...jest.requireActual('@strapi/admin/strapi-admin'),
|
||||
useAuth: (_consumerName: string, selector: (state: any) => any) =>
|
||||
selector({
|
||||
user: {
|
||||
firstname: 'Ted',
|
||||
lastname: 'Lasso',
|
||||
email: 'ted.lasso@afcrichmond.co.uk',
|
||||
roles: [
|
||||
{ id: 1, name: 'Super Admin' },
|
||||
{ id: 2, name: 'Editor' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Homepage Widget Profile component', () => {
|
||||
it('should render the widget with correct user info', async () => {
|
||||
render(<ProfileWidget />);
|
||||
|
||||
expect(await screen.findByText('Ted Lasso')).toBeInTheDocument();
|
||||
expect(await screen.findByText('ted.lasso@afcrichmond.co.uk')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Super Admin')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -6,7 +6,6 @@ import { useInitQuery, useTelemetryPropertiesQuery } from '../services/admin';
|
||||
|
||||
import { useAppInfo } from './AppInfo';
|
||||
import { useAuth } from './Auth';
|
||||
import { useStrapiApp } from './StrapiApp';
|
||||
|
||||
export interface TelemetryProperties {
|
||||
useTypescriptOnServer?: boolean;
|
||||
@ -40,42 +39,12 @@ export interface TrackingProviderProps {
|
||||
|
||||
const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
||||
const token = useAuth('App', (state) => state.token);
|
||||
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
||||
const { data: initData } = useInitQuery();
|
||||
const { uuid } = initData ?? {};
|
||||
|
||||
const { data } = useTelemetryPropertiesQuery(undefined, {
|
||||
skip: !initData?.uuid || !token,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (uuid && data) {
|
||||
const event = 'didInitializeAdministration';
|
||||
try {
|
||||
fetch('https://analytics.strapi.io/api/v2/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
// This event is anonymous
|
||||
event,
|
||||
userId: '',
|
||||
eventPropeties: {},
|
||||
groupProperties: {
|
||||
...data,
|
||||
projectId: uuid,
|
||||
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Strapi-Event': event,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// silence is golden
|
||||
}
|
||||
}
|
||||
}, [data, uuid, getAllWidgets]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
uuid,
|
||||
@ -101,7 +70,6 @@ const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
||||
interface EventWithoutProperties {
|
||||
name:
|
||||
| 'changeComponentsOrder'
|
||||
| 'didAccessAuthenticatedAdministration'
|
||||
| 'didAddComponentToDynamicZone'
|
||||
| 'didBulkDeleteEntries'
|
||||
| 'didNotBulkDeleteEntries'
|
||||
@ -197,6 +165,14 @@ interface EventWithoutProperties {
|
||||
properties?: never;
|
||||
}
|
||||
|
||||
interface DidAccessAuthenticatedAdministrationEvent {
|
||||
name: 'didAccessAuthenticatedAdministration';
|
||||
properties: {
|
||||
registeredWidgets: string[];
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DidFilterMediaLibraryElementsEvent {
|
||||
name: 'didFilterMediaLibraryElements';
|
||||
properties: MediaEvents['properties'] & {
|
||||
@ -384,6 +360,7 @@ interface DidUpdateCTBSchema {
|
||||
type EventsWithProperties =
|
||||
| CreateEntryEvents
|
||||
| PublishEntryEvents
|
||||
| DidAccessAuthenticatedAdministrationEvent
|
||||
| DidAccessTokenListEvent
|
||||
| DidChangeModeEvent
|
||||
| DidCropFileEvent
|
||||
|
@ -52,13 +52,13 @@ describe('useTracking', () => {
|
||||
it('should call axios.post with all attributes by default when calling trackUsage()', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
const res = await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).toBeCalledWith(
|
||||
'https://analytics.strapi.io/api/v2/track',
|
||||
{
|
||||
userId: 'someTestUserId',
|
||||
event: 'didAccessAuthenticatedAdministration',
|
||||
event: 'didSaveContentType',
|
||||
eventProperties: {},
|
||||
groupProperties: {
|
||||
useTypescriptOnServer: true,
|
||||
@ -70,7 +70,7 @@ describe('useTracking', () => {
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Strapi-Event': 'didAccessAuthenticatedAdministration',
|
||||
'X-Strapi-Event': 'didSaveContentType',
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -85,7 +85,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).not.toBeCalled();
|
||||
|
||||
@ -97,7 +97,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
const res = await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).toHaveBeenCalled();
|
||||
expect(res).toEqual(null);
|
||||
@ -114,7 +114,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).not.toBeCalled();
|
||||
});
|
||||
|
@ -26,6 +26,7 @@ export * from './components/SubNav';
|
||||
export * from './components/GradientBadge';
|
||||
|
||||
export { useGuidedTour } from './components/GuidedTour/Provider';
|
||||
export { tours as unstable_tours } from './components/UnstableGuidedTour/Tours';
|
||||
|
||||
/**
|
||||
* Features
|
||||
@ -78,6 +79,7 @@ export type { Widget as WidgetType } from './core/apis/Widgets';
|
||||
export { translatedErrors } from './utils/translatedErrors';
|
||||
export * from './utils/getFetchClient';
|
||||
export * from './utils/baseQuery';
|
||||
export * from './utils/rulesEngine';
|
||||
export * from './services/api';
|
||||
export type { CMAdminConfiguration } from './types/adminConfiguration';
|
||||
|
||||
|
@ -20,6 +20,7 @@ import { UpsellBanner } from '../components/UpsellBanner';
|
||||
import { AppInfoProvider } from '../features/AppInfo';
|
||||
import { useAuth } from '../features/Auth';
|
||||
import { useConfiguration } from '../features/Configuration';
|
||||
import { useStrapiApp } from '../features/StrapiApp';
|
||||
import { useTracking } from '../features/Tracking';
|
||||
import { useMenu } from '../hooks/useMenu';
|
||||
import { useOnce } from '../hooks/useOnce';
|
||||
@ -93,14 +94,16 @@ const AdminLayout = () => {
|
||||
pluginsSectionLinks,
|
||||
} = useMenu(checkLatestStrapiVersion(strapiVersion, tagName));
|
||||
|
||||
/**
|
||||
* Make sure the event is only send once after accessing the admin panel
|
||||
* and not at runtime for example when regenerating the permissions with the ctb
|
||||
* or with i18n
|
||||
*/
|
||||
useOnce(() => {
|
||||
trackUsage('didAccessAuthenticatedAdministration');
|
||||
});
|
||||
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
||||
const projectId = appInfo?.projectId;
|
||||
React.useEffect(() => {
|
||||
if (projectId) {
|
||||
trackUsage('didAccessAuthenticatedAdministration', {
|
||||
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
}, [projectId, getAllWidgets, trackUsage]);
|
||||
|
||||
// We don't need to wait for the release query to be fetched before rendering the plugins
|
||||
// however, we need the appInfos and the permissions
|
||||
|
@ -288,14 +288,10 @@ const TABLE_HEADERS: Array<
|
||||
cellFormatter({ isActive }) {
|
||||
return (
|
||||
<Flex>
|
||||
<Status
|
||||
size="S"
|
||||
borderWidth={0}
|
||||
background="transparent"
|
||||
color="neutral800"
|
||||
variant={isActive ? 'success' : 'danger'}
|
||||
>
|
||||
<Typography>{isActive ? 'Active' : 'Inactive'}</Typography>
|
||||
<Status size="S" variant={isActive ? 'success' : 'danger'}>
|
||||
<Typography tag="span" variant="omega" fontWeight="bold">
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</Typography>
|
||||
</Status>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
type UpdateProjectSettings,
|
||||
type Plugins,
|
||||
type GetLicenseLimitInformation,
|
||||
GetGuidedTourMeta,
|
||||
} from '../../../shared/contracts/admin';
|
||||
import { prefixFileUrlWithBackendUrl } from '../utils/urls';
|
||||
|
||||
@ -21,7 +22,7 @@ interface ConfigurationLogo {
|
||||
|
||||
const admin = adminApi
|
||||
.enhanceEndpoints({
|
||||
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft'],
|
||||
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft', 'GuidedTourMeta'],
|
||||
})
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@ -33,6 +34,7 @@ const admin = adminApi
|
||||
transformResponse(res: Init.Response) {
|
||||
return res.data;
|
||||
},
|
||||
providesTags: ['ProjectSettings'],
|
||||
}),
|
||||
information: builder.query<Information.Response['data'], void>({
|
||||
query: () => ({
|
||||
@ -114,6 +116,13 @@ const admin = adminApi
|
||||
}),
|
||||
providesTags: ['LicenseTrialTimeLeft'],
|
||||
}),
|
||||
getGuidedTourMeta: builder.query<GetGuidedTourMeta.Response, void>({
|
||||
query: () => ({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['GuidedTourMeta'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@ -127,6 +136,7 @@ const {
|
||||
useGetPluginsQuery,
|
||||
useGetLicenseLimitsQuery,
|
||||
useGetLicenseTrialTimeLeftQuery,
|
||||
useGetGuidedTourMetaQuery,
|
||||
} = admin;
|
||||
|
||||
export {
|
||||
@ -138,6 +148,7 @@ export {
|
||||
useGetPluginsQuery,
|
||||
useGetLicenseLimitsQuery,
|
||||
useGetLicenseTrialTimeLeftQuery,
|
||||
useGetGuidedTourMetaQuery,
|
||||
};
|
||||
|
||||
export type { ConfigurationLogo };
|
||||
|
@ -4,7 +4,7 @@ import { adminApi } from './api';
|
||||
|
||||
const apiTokensService = adminApi
|
||||
.enhanceEndpoints({
|
||||
addTagTypes: ['ApiToken'],
|
||||
addTagTypes: ['ApiToken', 'GuidedTourMeta'],
|
||||
})
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@ -31,7 +31,7 @@ const apiTokensService = adminApi
|
||||
data: body,
|
||||
}),
|
||||
transformResponse: (response: ApiToken.Create.Response) => response.data,
|
||||
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }],
|
||||
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }, 'GuidedTourMeta'],
|
||||
}),
|
||||
deleteAPIToken: builder.mutation<
|
||||
ApiToken.Revoke.Response['data'],
|
||||
|
@ -758,7 +758,8 @@
|
||||
"global.plugins.upload.description": "Media file management.",
|
||||
"global.plugins.users-permissions": "Roles & Permissions",
|
||||
"global.plugins.users-permissions.description": "Protect your API with a full authentication process based on JWT. This plugin comes also with an ACL strategy that allows you to manage the permissions between the groups of users.",
|
||||
"global.profile": "Profile settings",
|
||||
"global.profile": "Profile",
|
||||
"global.profile.settings": "Profile settings",
|
||||
"global.prompt.unsaved": "Are you sure you want to leave this page? All your modifications will be lost",
|
||||
"global.reset-password": "Reset password",
|
||||
"global.roles": "Roles",
|
||||
@ -799,5 +800,11 @@
|
||||
"request.error.model.unknown": "This model doesn't exist",
|
||||
"selectButtonTitle": "Select",
|
||||
"skipToContent": "Skip to content",
|
||||
"submit": "Submit"
|
||||
"submit": "Submit",
|
||||
"tours.contentManager.Introduction.title": "Content manager",
|
||||
"tours.contentManager.Introduction.content": "Create and manage content from your collection types and single types.",
|
||||
"tours.stepCount": "Step {currentStep} of {tourLength}",
|
||||
"tours.skip": "Skip",
|
||||
"tours.next": "Next",
|
||||
"widget.profile.title": "Profile"
|
||||
}
|
||||
|
@ -555,6 +555,7 @@
|
||||
"global.plugins.users-permissions": "Roles & Permisos",
|
||||
"global.plugins.users-permissions.description": "Proteja su API con un proceso de autenticación completo basado en JWT. Este complemento también viene con una estrategia ACL que le permite administrar los permisos entre los grupos de usuarios.",
|
||||
"global.profile": "Perfil",
|
||||
"global.profile.settings": "Ajustes del perfil",
|
||||
"global.reset-password": "Resetear contraseña",
|
||||
"global.roles": "Roles",
|
||||
"global.save": "Guardar",
|
||||
|
@ -504,6 +504,7 @@
|
||||
"global.password": "Mot de passe",
|
||||
"global.plugins": "Plugins",
|
||||
"global.profile": "Profil",
|
||||
"global.profile.settings": "Paramètres du profil",
|
||||
"global.reset-password": "Réinitialiser le mot de passe",
|
||||
"global.roles": "Rôles",
|
||||
"global.save": "Enregistrer",
|
||||
@ -561,5 +562,6 @@
|
||||
"components.Blocks.blocks.quote": "Citation",
|
||||
"components.Blocks.blocks.image": "Image",
|
||||
"components.Blocks.blocks.unorderedList": "Liste à puces",
|
||||
"components.Blocks.blocks.orderedList": "Liste numérotée"
|
||||
"components.Blocks.blocks.orderedList": "Liste numérotée",
|
||||
"widget.profile.title": "Profil"
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createRulesEngine, Condition } from '../rules-engine';
|
||||
import { createRulesEngine, Condition } from '../rulesEngine';
|
||||
|
||||
describe('RulesEngine with is & isNot operator', () => {
|
||||
const engine = createRulesEngine();
|
@ -1,11 +1,55 @@
|
||||
import crypto from 'crypto';
|
||||
import { TextEncoder } from 'util';
|
||||
|
||||
import { hashAdminUserEmail } from '../users';
|
||||
import { hashAdminUserEmail, getInitials, getDisplayName } from '../users';
|
||||
|
||||
const testHashValue = '8544bf5b5389959462912699664f03ed664a4b6d24f03b13bdbc362efc147873';
|
||||
|
||||
describe('users', () => {
|
||||
describe('getDisplayName', () => {
|
||||
it('returns username if present', () => {
|
||||
expect(getDisplayName({ username: 'foobar' })).toBe('foobar');
|
||||
});
|
||||
|
||||
it('returns firstname and lastname if no username present', () => {
|
||||
expect(getDisplayName({ firstname: 'John', lastname: 'Doe' })).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('returns only firstname if lastname is missing', () => {
|
||||
expect(getDisplayName({ firstname: 'Alice' })).toBe('Alice');
|
||||
});
|
||||
|
||||
it('returns email if no names', () => {
|
||||
expect(getDisplayName({ email: 'user@example.com' })).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('returns empty string if no fields provided', () => {
|
||||
expect(getDisplayName({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitials', () => {
|
||||
it('returns initials from firstname and lastname', () => {
|
||||
expect(getInitials({ firstname: 'John', lastname: 'Doe' })).toBe('JD');
|
||||
});
|
||||
|
||||
it('returns first letter of firstname if no lastname', () => {
|
||||
expect(getInitials({ firstname: 'Alice' })).toBe('A');
|
||||
});
|
||||
|
||||
it('returns first letter of username if no firstname/lastname', () => {
|
||||
expect(getInitials({ username: 'foobar' })).toBe('F');
|
||||
});
|
||||
|
||||
it('returns first letter of email if no name fields', () => {
|
||||
expect(getInitials({ email: 'user@example.com' })).toBe('U');
|
||||
});
|
||||
|
||||
it('returns empty string for empty object', () => {
|
||||
expect(getInitials({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashAdminUserEmail', () => {
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window.self, 'crypto', {
|
||||
|
@ -20,6 +20,24 @@ const getDisplayName = ({ firstname, lastname, username, email }: Partial<User>
|
||||
return email ?? '';
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* getInitials
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Retrieves the initials of the user (based on their firstname / lastname or their display name)
|
||||
*/
|
||||
const getInitials = (user: Partial<User> = {}): string => {
|
||||
return user?.firstname && user?.lastname
|
||||
? `${user.firstname.substring(0, 1)}${user.lastname.substring(0, 1)}`
|
||||
: getDisplayName(user)
|
||||
.split(' ')
|
||||
.map((name) => name.substring(0, 1))
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* hashAdminUserEmail
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
@ -46,4 +64,4 @@ const digestMessage = async (message: string) => {
|
||||
return bufferToHex(hashBuffer);
|
||||
};
|
||||
|
||||
export { getDisplayName, hashAdminUserEmail };
|
||||
export { getDisplayName, getInitials, hashAdminUserEmail };
|
||||
|
@ -318,6 +318,16 @@ export const server: SetupServer = setupServer(
|
||||
})
|
||||
);
|
||||
}),
|
||||
rest.get('/admin/guided-tour-meta', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
isFirstSuperAdminUser: false,
|
||||
completedActions: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
}),
|
||||
/**
|
||||
* WEBHOOKS
|
||||
*/
|
||||
|
@ -1,4 +1,5 @@
|
||||
/* eslint-disable check-file/filename-naming-convention */
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { ConfigureStoreOptions, configureStore } from '@reduxjs/toolkit';
|
||||
@ -27,6 +28,7 @@ import { MemoryRouterProps, RouterProvider, createMemoryRouter } from 'react-rou
|
||||
import { GuidedTourProvider } from '../src/components/GuidedTour/Provider';
|
||||
import { LanguageProvider } from '../src/components/LanguageProvider';
|
||||
import { Theme } from '../src/components/Theme';
|
||||
import { UnstableGuidedTourContext } from '../src/components/UnstableGuidedTour/Context';
|
||||
import { RBAC } from '../src/core/apis/rbac';
|
||||
import { AppInfoProvider } from '../src/features/AppInfo';
|
||||
import { AuthProvider, type Permission } from '../src/features/Auth';
|
||||
@ -158,28 +160,30 @@ const Providers = ({ children, initialEntries, storeConfig, permissions = [] }:
|
||||
>
|
||||
<NotificationsProvider>
|
||||
<GuidedTourProvider>
|
||||
<ConfigurationContextProvider
|
||||
showReleaseNotification={false}
|
||||
logos={{
|
||||
auth: { default: 'default' },
|
||||
menu: { default: 'default' },
|
||||
}}
|
||||
updateProjectSettings={jest.fn()}
|
||||
>
|
||||
<AppInfoProvider
|
||||
autoReload
|
||||
useYarn
|
||||
dependencies={{
|
||||
'@strapi/plugin-documentation': '4.2.0',
|
||||
'@strapi/provider-upload-cloudinary': '4.2.0',
|
||||
<UnstableGuidedTourContext enabled={false}>
|
||||
<ConfigurationContextProvider
|
||||
showReleaseNotification={false}
|
||||
logos={{
|
||||
auth: { default: 'default' },
|
||||
menu: { default: 'default' },
|
||||
}}
|
||||
strapiVersion="4.1.0"
|
||||
communityEdition
|
||||
shouldUpdateStrapi={false}
|
||||
updateProjectSettings={jest.fn()}
|
||||
>
|
||||
{children}
|
||||
</AppInfoProvider>
|
||||
</ConfigurationContextProvider>
|
||||
<AppInfoProvider
|
||||
autoReload
|
||||
useYarn
|
||||
dependencies={{
|
||||
'@strapi/plugin-documentation': '4.2.0',
|
||||
'@strapi/provider-upload-cloudinary': '4.2.0',
|
||||
}}
|
||||
strapiVersion="4.1.0"
|
||||
communityEdition
|
||||
shouldUpdateStrapi={false}
|
||||
>
|
||||
{children}
|
||||
</AppInfoProvider>
|
||||
</ConfigurationContextProvider>
|
||||
</UnstableGuidedTourContext>
|
||||
</GuidedTourProvider>
|
||||
</NotificationsProvider>
|
||||
</Theme>
|
||||
|
@ -7,20 +7,7 @@ export default {
|
||||
path: '/license-limit-information',
|
||||
handler: 'admin.licenseLimitInformation',
|
||||
config: {
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'admin::hasPermissions',
|
||||
config: {
|
||||
actions: [
|
||||
'admin::users.create',
|
||||
'admin::users.read',
|
||||
'admin::users.update',
|
||||
'admin::users.delete',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/admin",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Strapi Admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -84,12 +84,12 @@
|
||||
"@radix-ui/react-context": "1.0.1",
|
||||
"@radix-ui/react-toolbar": "1.0.4",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/permissions": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/typescript-utils": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/permissions": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@strapi/typescript-utils": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"@testing-library/dom": "10.1.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
@ -110,6 +110,7 @@
|
||||
"inquirer": "8.2.5",
|
||||
"invariant": "^2.2.4",
|
||||
"is-localhost-ip": "2.0.0",
|
||||
"json-logic-js": "2.0.5",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"koa": "2.16.1",
|
||||
"koa-compose": "4.1.0",
|
||||
@ -143,11 +144,12 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin-test-utils": "5.16.0",
|
||||
"@strapi/data-transfer": "5.16.0",
|
||||
"@strapi/admin-test-utils": "5.17.0",
|
||||
"@strapi/data-transfer": "5.17.0",
|
||||
"@types/codemirror5": "npm:@types/codemirror@^5.60.15",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/invariant": "2.2.36",
|
||||
"@types/json-logic-js": "2.0.8",
|
||||
"@types/jsonwebtoken": "9.0.3",
|
||||
"@types/koa-passport": "6.0.1",
|
||||
"@types/lodash": "^4.14.191",
|
||||
|
@ -4,6 +4,7 @@ import { async } from '@strapi/utils';
|
||||
import { getService } from './utils';
|
||||
import adminActions from './config/admin-actions';
|
||||
import adminConditions from './config/admin-conditions';
|
||||
import constants from './services/constants';
|
||||
|
||||
const defaultAdminAuthSettings = {
|
||||
providers: {
|
||||
@ -88,21 +89,9 @@ const createDefaultAPITokensIfNeeded = async () => {
|
||||
const apiTokenCount = await apiTokenService.count();
|
||||
|
||||
if (usersCount === 0 && apiTokenCount === 0) {
|
||||
await apiTokenService.create({
|
||||
name: 'Read Only',
|
||||
description:
|
||||
'A default API token with read-only permissions, only used for accessing resources',
|
||||
type: 'read-only',
|
||||
lifespan: null,
|
||||
});
|
||||
|
||||
await apiTokenService.create({
|
||||
name: 'Full Access',
|
||||
description:
|
||||
'A default API token with full access permissions, used for accessing or modifying resources',
|
||||
type: 'full-access',
|
||||
lifespan: null,
|
||||
});
|
||||
for (const token of constants.DEFAULT_API_TOKENS) {
|
||||
await apiTokenService.create(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -5,7 +5,6 @@ import path from 'path';
|
||||
import { map, values, sumBy, pipe, flatMap, propEq } from 'lodash/fp';
|
||||
import _ from 'lodash';
|
||||
import { exists } from 'fs-extra';
|
||||
import '@strapi/types';
|
||||
import { env } from '@strapi/utils';
|
||||
import tsUtils from '@strapi/typescript-utils';
|
||||
import {
|
||||
@ -22,6 +21,7 @@ import type {
|
||||
Plugins,
|
||||
TelemetryProperties,
|
||||
UpdateProjectSettings,
|
||||
GetGuidedTourMeta,
|
||||
} from '../../../shared/contracts/admin';
|
||||
|
||||
const { isUsingTypeScript } = tsUtils;
|
||||
@ -185,4 +185,18 @@ export default {
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async getGuidedTourMeta(ctx: Context) {
|
||||
const [isFirstSuperAdminUser, completedActions] = await Promise.all([
|
||||
getService('user').isFirstSuperAdminUser(ctx.state.user.id),
|
||||
getService('guided-tour').getCompletedActions(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: {
|
||||
isFirstSuperAdminUser,
|
||||
completedActions,
|
||||
},
|
||||
} satisfies GetGuidedTourMeta.Response;
|
||||
},
|
||||
};
|
||||
|
@ -74,4 +74,12 @@ export default [
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/guided-tour-meta',
|
||||
handler: 'admin.getGuidedTourMeta',
|
||||
config: {
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -22,6 +22,22 @@ const constants = {
|
||||
DAYS_30: 30 * DAY_IN_MS,
|
||||
DAYS_90: 90 * DAY_IN_MS,
|
||||
},
|
||||
DEFAULT_API_TOKENS: [
|
||||
{
|
||||
name: 'Read Only',
|
||||
description:
|
||||
'A default API token with read-only permissions, only used for accessing resources',
|
||||
type: 'read-only',
|
||||
lifespan: null,
|
||||
},
|
||||
{
|
||||
name: 'Full Access',
|
||||
description:
|
||||
'A default API token with full access permissions, used for accessing or modifying resources',
|
||||
type: 'full-access',
|
||||
lifespan: null,
|
||||
},
|
||||
] as const,
|
||||
TRANSFER_TOKEN_TYPE: {
|
||||
PUSH: 'push',
|
||||
PULL: 'pull',
|
||||
|
77
packages/core/admin/server/src/services/guided-tour.ts
Normal file
77
packages/core/admin/server/src/services/guided-tour.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { Core, Internal } from '@strapi/types';
|
||||
import constants from './constants';
|
||||
|
||||
export type GuidedTourRequiredActions = {
|
||||
didCreateContentTypeSchema: boolean;
|
||||
didCreateContent: boolean;
|
||||
didCreateApiToken: boolean;
|
||||
};
|
||||
export type GuidedTourCompletedActions = keyof GuidedTourRequiredActions;
|
||||
|
||||
const DEFAULT_ATTIBUTES = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'publishedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'locale',
|
||||
'localizations',
|
||||
];
|
||||
|
||||
export const createGuidedTourService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
const getCompletedActions = async () => {
|
||||
// Check if any content-type schemas have been created on the api:: namespace
|
||||
const contentTypeSchemaNames = Object.keys(strapi.contentTypes).filter((contentTypeUid) =>
|
||||
contentTypeUid.startsWith('api::')
|
||||
);
|
||||
const contentTypeSchemaAttributes = contentTypeSchemaNames.map((uid) => {
|
||||
const attributes = Object.keys(
|
||||
strapi.contentType(uid as Internal.UID.ContentType).attributes
|
||||
);
|
||||
return attributes.filter((attribute) => !DEFAULT_ATTIBUTES.includes(attribute));
|
||||
});
|
||||
const didCreateContentTypeSchema = (() => {
|
||||
if (contentTypeSchemaNames.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return contentTypeSchemaAttributes.some((attributes) => attributes.length > 0);
|
||||
})();
|
||||
|
||||
// Check if any content has been created for content-types on the api:: namespace
|
||||
const hasContent = await (async () => {
|
||||
for (const name of contentTypeSchemaNames) {
|
||||
const count = await strapi.documents(name as Internal.UID.ContentType).count({});
|
||||
|
||||
if (count > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
const didCreateContent = didCreateContentTypeSchema && hasContent;
|
||||
|
||||
// Check if any api tokens have been created besides the default ones
|
||||
const createdApiTokens = await strapi
|
||||
.documents('admin::api-token')
|
||||
.findMany({ fields: ['name', 'description'] });
|
||||
const didCreateApiToken = createdApiTokens.some((doc) =>
|
||||
constants.DEFAULT_API_TOKENS.every(
|
||||
(token) => token.name !== doc.name && token.description !== doc.description
|
||||
)
|
||||
);
|
||||
|
||||
// Compute an array of action names that have been completed
|
||||
const requiredActions = {
|
||||
didCreateContentTypeSchema,
|
||||
didCreateContent,
|
||||
didCreateApiToken,
|
||||
};
|
||||
const requiredActionNames = Object.keys(requiredActions) as Array<GuidedTourCompletedActions>;
|
||||
const completedActions = requiredActionNames.filter((key) => requiredActions[key]);
|
||||
|
||||
return completedActions;
|
||||
};
|
||||
|
||||
return {
|
||||
getCompletedActions,
|
||||
};
|
||||
};
|
@ -14,6 +14,7 @@ import * as action from './action';
|
||||
import * as apiToken from './api-token';
|
||||
import * as transfer from './transfer';
|
||||
import * as projectSettings from './project-settings';
|
||||
import { createGuidedTourService } from './guided-tour';
|
||||
|
||||
// TODO: TS - Export services one by one as this export is cjs
|
||||
export default {
|
||||
@ -32,4 +33,5 @@ export default {
|
||||
transfer,
|
||||
'project-settings': projectSettings,
|
||||
encryption,
|
||||
'guided-tour': createGuidedTourService,
|
||||
};
|
||||
|
@ -161,6 +161,31 @@ const isLastSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
|
||||
return superAdminRole.usersCount === 1 && hasSuperAdminRole(user);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user is the first super admin
|
||||
* @param userId user's id to look for
|
||||
*/
|
||||
const isFirstSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
|
||||
const currentUser = (await findOne(userId)) as AdminUser | null;
|
||||
|
||||
if (!currentUser || !hasSuperAdminRole(currentUser)) return false;
|
||||
|
||||
const [oldestUser] = await strapi.db.query('admin::user').findMany({
|
||||
populate: {
|
||||
roles: {
|
||||
where: {
|
||||
code: { $eq: SUPER_ADMIN_CODE },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
limit: 1,
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
return oldestUser.id === currentUser.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user with specific attributes exists in the database
|
||||
* @param attributes A partial user object
|
||||
@ -390,4 +415,5 @@ export default {
|
||||
displayWarningIfUsersDontHaveRole,
|
||||
resetPasswordByEmail,
|
||||
getLanguagesInUse,
|
||||
isFirstSuperAdminUser,
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ import * as token from '../services/token';
|
||||
import * as apiToken from '../services/api-token';
|
||||
import * as projectSettings from '../services/project-settings';
|
||||
import * as transfer from '../services/transfer';
|
||||
import { createGuidedTourService } from '../services/guided-tour';
|
||||
|
||||
type S = {
|
||||
role: typeof role;
|
||||
@ -24,6 +25,7 @@ type S = {
|
||||
'project-settings': typeof projectSettings;
|
||||
transfer: typeof transfer;
|
||||
encryption: typeof encryption;
|
||||
'guided-tour': ReturnType<typeof createGuidedTourService>;
|
||||
};
|
||||
|
||||
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Data } from '@strapi/types';
|
||||
import { errors } from '@strapi/utils';
|
||||
import type { File } from 'formidable';
|
||||
import type { GuidedTourCompletedActions } from '../../server/src/services/guided-tour';
|
||||
|
||||
export interface Logo {
|
||||
name: string;
|
||||
@ -218,3 +220,18 @@ export declare namespace GetLicenseLimitInformation {
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta data for the guided tour
|
||||
*/
|
||||
export declare namespace GetGuidedTourMeta {
|
||||
export interface Request {}
|
||||
|
||||
export interface Response {
|
||||
data: {
|
||||
isFirstSuperAdminUser: boolean;
|
||||
completedActions: GuidedTourCompletedActions[];
|
||||
};
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Form, Layouts } from '@strapi/admin/strapi-admin';
|
||||
import { Form, Layouts, useForm, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Divider, Flex, Grid, Typography } from '@strapi/design-system';
|
||||
import pipe from 'lodash/fp/pipe';
|
||||
import { useIntl } from 'react-intl';
|
||||
@ -103,9 +103,19 @@ function getRemaingFieldsLayout({
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
||||
const fieldValues = useForm('Fields', (state) => state.values);
|
||||
const rulesEngine = createRulesEngine();
|
||||
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||
const [row] = panel;
|
||||
const [field] = row;
|
||||
const condition = field.attribute?.conditions?.visible;
|
||||
|
||||
if (condition) {
|
||||
const isVisible = rulesEngine.evaluate(condition, fieldValues);
|
||||
if (!isVisible) {
|
||||
return null; // Skip rendering the dynamic zone if the condition is not met
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid.Root key={field.name} gap={4}>
|
||||
@ -128,24 +138,40 @@ const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
||||
borderColor="neutral150"
|
||||
>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{panel.map((row, gridRowIndex) => (
|
||||
<Grid.Root key={gridRowIndex} gap={4}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
return (
|
||||
<Grid.Item
|
||||
col={size}
|
||||
key={field.name}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<VersionInputRenderer {...field} />
|
||||
</Grid.Item>
|
||||
);
|
||||
})}
|
||||
</Grid.Root>
|
||||
))}
|
||||
{panel.map((row, gridRowIndex) => {
|
||||
const visibleFields = row.filter((field) => {
|
||||
const condition = field.attribute?.conditions?.visible;
|
||||
|
||||
if (condition) {
|
||||
return rulesEngine.evaluate(condition, fieldValues);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null; // Skip rendering the entire grid row
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid.Root key={gridRowIndex} gap={4}>
|
||||
{visibleFields.map(({ size, ...field }) => {
|
||||
return (
|
||||
<Grid.Item
|
||||
col={size}
|
||||
key={field.name}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<VersionInputRenderer {...field} />
|
||||
</Grid.Item>
|
||||
);
|
||||
})}
|
||||
</Grid.Root>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
@ -27,6 +27,7 @@ import { createYupSchema } from '../../utils/validation';
|
||||
import { FormLayout } from './components/FormLayout';
|
||||
import { Header } from './components/Header';
|
||||
import { Panels } from './components/Panels';
|
||||
import { handleInvisibleAttributes } from './utils/data';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* EditViewPage
|
||||
@ -143,12 +144,22 @@ const EditViewPage = () => {
|
||||
initialValues={initialValues}
|
||||
method={isCreatingDocument ? 'POST' : 'PUT'}
|
||||
validate={(values: Record<string, unknown>, options: Record<string, string>) => {
|
||||
// removes hidden fields from the validation
|
||||
// this is necessary because the yup schema doesn't know about the visibility conditions
|
||||
// and we don't want to validate fields that are not visible
|
||||
const { data: cleanedValues, removedAttributes } = handleInvisibleAttributes(values, {
|
||||
schema,
|
||||
initialValues,
|
||||
components,
|
||||
});
|
||||
|
||||
const yupSchema = createYupSchema(schema?.attributes, components, {
|
||||
status,
|
||||
removedAttributes,
|
||||
...options,
|
||||
});
|
||||
|
||||
return yupSchema.validate(values, { abortEarly: false });
|
||||
return yupSchema.validate(cleanedValues, { abortEarly: false });
|
||||
}}
|
||||
initialErrors={location?.state?.forceValidation ? validateSync(initialValues, {}) : {}}
|
||||
>
|
||||
|
@ -41,7 +41,7 @@ import {
|
||||
} from '../../../services/documents';
|
||||
import { isBaseQueryError, buildValidParams } from '../../../utils/api';
|
||||
import { getTranslation } from '../../../utils/translations';
|
||||
import { AnyData } from '../utils/data';
|
||||
import { AnyData, handleInvisibleAttributes } from '../utils/data';
|
||||
|
||||
import { useRelationModal } from './FormInputs/Relations/RelationModal';
|
||||
|
||||
@ -555,6 +555,9 @@ const PublishAction: DocumentActionComponent = ({
|
||||
const setErrors = useForm('PublishAction', (state) => state.setErrors);
|
||||
const formValues = useForm('PublishAction', ({ values }) => values);
|
||||
const resetForm = useForm('PublishAction', ({ resetForm }) => resetForm);
|
||||
const {
|
||||
currentDocument: { components },
|
||||
} = useDocumentContext('PublishAction');
|
||||
|
||||
// need to discriminate if the publish is coming from a relation modal or in the edit view
|
||||
const relationContext = useRelationModal('PublishAction', () => true, false);
|
||||
@ -722,6 +725,10 @@ const PublishAction: DocumentActionComponent = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { data } = handleInvisibleAttributes(transformData(formValues), {
|
||||
schema,
|
||||
components,
|
||||
});
|
||||
const res = await publish(
|
||||
{
|
||||
collectionType,
|
||||
@ -729,7 +736,7 @@ const PublishAction: DocumentActionComponent = ({
|
||||
documentId,
|
||||
params: currentDocumentMeta.params,
|
||||
},
|
||||
transformData(formValues)
|
||||
data
|
||||
);
|
||||
|
||||
// Reset form if successful
|
||||
@ -909,6 +916,9 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
const isCloning = cloneMatch !== null;
|
||||
const { formatMessage } = useIntl();
|
||||
const { create, update, clone, isLoading } = useDocumentActions();
|
||||
const {
|
||||
currentDocument: { components },
|
||||
} = useDocumentContext('UpdateAction');
|
||||
const [{ rawQuery }] = useQueryParams();
|
||||
const onPreview = usePreviewContext('UpdateAction', (state) => state.onPreview, false);
|
||||
const { getInitialFormValues } = useDoc();
|
||||
@ -916,6 +926,7 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
const isSubmitting = useForm('UpdateAction', ({ isSubmitting }) => isSubmitting);
|
||||
const modified = useForm('UpdateAction', ({ modified }) => modified);
|
||||
const setSubmitting = useForm('UpdateAction', ({ setSubmitting }) => setSubmitting);
|
||||
const initialValues = useForm('UpdateAction', ({ initialValues }) => initialValues);
|
||||
const document = useForm('UpdateAction', ({ values }) => values);
|
||||
const validate = useForm('UpdateAction', (state) => state.validate);
|
||||
const setErrors = useForm('UpdateAction', (state) => state.setErrors);
|
||||
@ -925,6 +936,11 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
|
||||
// need to discriminate if the update is coming from a relation modal or in the edit view
|
||||
const relationContext = useRelationModal('UpdateAction', () => true, false);
|
||||
const relationalModalSchema = useRelationModal(
|
||||
'UpdateAction',
|
||||
(state) => state.currentDocument.schema,
|
||||
false
|
||||
);
|
||||
const fieldToConnect = useRelationModal(
|
||||
'UpdateAction',
|
||||
(state) => state.state.fieldToConnect,
|
||||
@ -956,6 +972,7 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
},
|
||||
{ skip: !parentDocumentMetaToUpdate }
|
||||
);
|
||||
const { schema } = useDoc();
|
||||
|
||||
const handleUpdate = React.useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
@ -981,7 +998,6 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloning) {
|
||||
const res = await clone(
|
||||
{
|
||||
@ -1008,6 +1024,11 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
setErrors(formatValidationErrors(res.error));
|
||||
}
|
||||
} else if (documentId || collectionType === SINGLE_TYPES) {
|
||||
const { data } = handleInvisibleAttributes(transformData(document), {
|
||||
schema: fromRelationModal ? relationalModalSchema : schema,
|
||||
initialValues,
|
||||
components,
|
||||
});
|
||||
const res = await update(
|
||||
{
|
||||
collectionType,
|
||||
@ -1015,7 +1036,7 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
documentId,
|
||||
params: currentDocumentMeta.params,
|
||||
},
|
||||
transformData(document)
|
||||
data
|
||||
);
|
||||
|
||||
if ('error' in res && isBaseQueryError(res.error) && res.error.name === 'ValidationError') {
|
||||
@ -1024,12 +1045,17 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
resetForm();
|
||||
}
|
||||
} else {
|
||||
const { data } = handleInvisibleAttributes(transformData(document), {
|
||||
schema: fromRelationModal ? relationalModalSchema : schema,
|
||||
initialValues,
|
||||
components,
|
||||
});
|
||||
const res = await create(
|
||||
{
|
||||
model,
|
||||
params: currentDocumentMeta.params,
|
||||
},
|
||||
transformData(document)
|
||||
data
|
||||
);
|
||||
|
||||
if ('data' in res && collectionType !== SINGLE_TYPES) {
|
||||
@ -1152,6 +1178,10 @@ const UpdateAction: DocumentActionComponent = ({
|
||||
updateDocumentMutation,
|
||||
formatAPIError,
|
||||
onPreview,
|
||||
initialValues,
|
||||
schema,
|
||||
components,
|
||||
relationalModalSchema,
|
||||
]);
|
||||
|
||||
// Auto-save on CMD+S or CMD+Enter on macOS, and CTRL+S or CTRL+Enter on Windows/Linux
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useField } from '@strapi/admin/strapi-admin';
|
||||
import { useField, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Flex } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@ -21,6 +21,7 @@ const NonRepeatableComponent = ({
|
||||
const level = useComponent('NonRepeatableComponent', (state) => state.level);
|
||||
const isNested = level > 0;
|
||||
const { currentDocument } = useDocumentContext('NonRepeatableComponent');
|
||||
const rulesEngine = createRulesEngine();
|
||||
|
||||
return (
|
||||
<ComponentProvider id={value?.id} uid={attribute.component} level={level + 1} type="component">
|
||||
@ -35,9 +36,21 @@ const NonRepeatableComponent = ({
|
||||
>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{layout.map((row, index) => {
|
||||
const visibleFields = row.filter(({ ...field }) => {
|
||||
const condition = field.attribute.conditions?.visible;
|
||||
if (condition) {
|
||||
return rulesEngine.evaluate(condition, value);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null; // Skip rendering the entire grid row
|
||||
}
|
||||
return (
|
||||
<ResponsiveGridRoot gap={4} key={index}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
{visibleFields.map(({ size, ...field }) => {
|
||||
/**
|
||||
* Layouts are built from schemas so they don't understand the complete
|
||||
* schema tree, for components we append the parent name to the field name
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useField, useNotification, useForm } from '@strapi/admin/strapi-admin';
|
||||
import { useField, useNotification, useForm, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@ -67,6 +67,8 @@ const RepeatableComponent = ({
|
||||
const [collapseToOpen, setCollapseToOpen] = React.useState<string>('');
|
||||
const [liveText, setLiveText] = React.useState('');
|
||||
|
||||
const rulesEngine = createRulesEngine();
|
||||
|
||||
React.useEffect(() => {
|
||||
const hasNestedErrors = rawError && Array.isArray(rawError) && rawError.length > 0;
|
||||
const hasNestedValue = value && Array.isArray(value) && value.length > 0;
|
||||
@ -244,8 +246,9 @@ const RepeatableComponent = ({
|
||||
onValueChange={handleValueChange}
|
||||
aria-describedby={ariaDescriptionId}
|
||||
>
|
||||
{value.map(({ __temp_key__: key, id }, index) => {
|
||||
{value.map(({ __temp_key__: key, id, ...currentComponentValues }, index) => {
|
||||
const nameWithIndex = `${name}.${index}`;
|
||||
|
||||
return (
|
||||
<ComponentProvider
|
||||
key={key}
|
||||
@ -273,9 +276,21 @@ const RepeatableComponent = ({
|
||||
__temp_key__={key}
|
||||
>
|
||||
{layout.map((row, index) => {
|
||||
const visibleFields = row.filter(({ ...field }) => {
|
||||
const condition = field.attribute.conditions?.visible;
|
||||
if (condition) {
|
||||
return rulesEngine.evaluate(condition, currentComponentValues);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null; // Skip rendering the entire grid row
|
||||
}
|
||||
return (
|
||||
<ResponsiveGridRoot gap={4} key={index}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
{visibleFields.map(({ size, ...field }) => {
|
||||
/**
|
||||
* Layouts are built from schemas so they don't understand the complete
|
||||
* schema tree, for components we append the parent name to the field name
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useForm, useField } from '@strapi/admin/strapi-admin';
|
||||
import { useForm, useField, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Accordion,
|
||||
Box,
|
||||
@ -58,6 +58,7 @@ const DynamicComponent = ({
|
||||
const { formatMessage } = useIntl();
|
||||
const formValues = useForm('DynamicComponent', (state) => state.values);
|
||||
const { currentDocument, currentDocumentMeta } = useDocumentContext('DynamicComponent');
|
||||
const rulesEngine = createRulesEngine();
|
||||
|
||||
const {
|
||||
edit: { components },
|
||||
@ -238,55 +239,70 @@ const DynamicComponent = ({
|
||||
<AccordionContentRadius background="neutral0">
|
||||
<Box paddingLeft={6} paddingRight={6} paddingTop={6} paddingBottom={6}>
|
||||
<Grid.Root gap={4}>
|
||||
{components[componentUid]?.layout?.map((row, rowInd) => (
|
||||
<Grid.Item
|
||||
col={12}
|
||||
key={rowInd}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<ResponsiveGridRoot gap={4}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
const fieldName = `${name}.${index}.${field.name}`;
|
||||
{components[componentUid]?.layout?.map((row, rowInd) => {
|
||||
const visibleFields = row.filter(({ ...field }) => {
|
||||
const condition = field.attribute.conditions?.visible;
|
||||
|
||||
const fieldWithTranslatedLabel = {
|
||||
...field,
|
||||
label: formatMessage({
|
||||
id: `content-manager.components.${componentUid}.${field.name}`,
|
||||
defaultMessage: field.label,
|
||||
}),
|
||||
};
|
||||
if (condition) {
|
||||
return rulesEngine.evaluate(condition, value);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveGridItem
|
||||
col={size}
|
||||
key={fieldName}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
{children ? (
|
||||
children({
|
||||
...fieldWithTranslatedLabel,
|
||||
document: currentDocument,
|
||||
name: fieldName,
|
||||
})
|
||||
) : (
|
||||
<InputRenderer
|
||||
{...fieldWithTranslatedLabel}
|
||||
document={currentDocument}
|
||||
name={fieldName}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveGridItem>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridRoot>
|
||||
</Grid.Item>
|
||||
))}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null; // Skip rendering the entire grid row
|
||||
}
|
||||
return (
|
||||
<Grid.Item
|
||||
col={12}
|
||||
key={rowInd}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<ResponsiveGridRoot gap={4}>
|
||||
{visibleFields.map(({ size, ...field }) => {
|
||||
const fieldName = `${name}.${index}.${field.name}`;
|
||||
|
||||
const fieldWithTranslatedLabel = {
|
||||
...field,
|
||||
label: formatMessage({
|
||||
id: `content-manager.components.${componentUid}.${field.name}`,
|
||||
defaultMessage: field.label,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveGridItem
|
||||
col={size}
|
||||
key={fieldName}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
{children ? (
|
||||
children({
|
||||
...fieldWithTranslatedLabel,
|
||||
document: currentDocument,
|
||||
name: fieldName,
|
||||
})
|
||||
) : (
|
||||
<InputRenderer
|
||||
{...fieldWithTranslatedLabel}
|
||||
document={currentDocument}
|
||||
name={fieldName}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveGridItem>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridRoot>
|
||||
</Grid.Item>
|
||||
);
|
||||
})}
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
</AccordionContentRadius>
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
useForm,
|
||||
useNotification,
|
||||
useFocusInputField,
|
||||
useRBAC,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Box,
|
||||
@ -21,8 +22,10 @@ import {
|
||||
Field,
|
||||
FlexComponent,
|
||||
BoxComponent,
|
||||
Loader,
|
||||
EmptyStateLayout,
|
||||
} from '@strapi/design-system';
|
||||
import { Cross, Drag, ArrowClockwise, Link as LinkIcon, Plus } from '@strapi/icons';
|
||||
import { Cross, Drag, ArrowClockwise, Link as LinkIcon, Plus, WarningCircle } from '@strapi/icons';
|
||||
import { generateNKeysBetween } from 'fractional-indexing';
|
||||
import pipe from 'lodash/fp/pipe';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
@ -33,6 +36,8 @@ import { styled } from 'styled-components';
|
||||
import { RelationDragPreviewProps } from '../../../../../components/DragPreviews/RelationDragPreview';
|
||||
import { COLLECTION_TYPES } from '../../../../../constants/collections';
|
||||
import { ItemTypes } from '../../../../../constants/dragAndDrop';
|
||||
import { PERMISSIONS } from '../../../../../constants/plugin';
|
||||
import { DocumentRBAC, useDocumentRBAC } from '../../../../../features/DocumentRBAC';
|
||||
import { useDebounce } from '../../../../../hooks/useDebounce';
|
||||
import { useDocument } from '../../../../../hooks/useDocument';
|
||||
import { type DocumentMeta, useDocumentContext } from '../../../../../hooks/useDocumentContext';
|
||||
@ -54,6 +59,7 @@ import { DocumentStatus } from '../../DocumentStatus';
|
||||
import { useComponent } from '../ComponentContext';
|
||||
import { RelationModalRenderer, getCollectionType } from '../Relations/RelationModal';
|
||||
|
||||
import type { FindAvailable } from '../../../../../../../shared/contracts/relations';
|
||||
import type { Schema } from '@strapi/types';
|
||||
|
||||
/**
|
||||
@ -484,7 +490,6 @@ const RelationsInput = ({
|
||||
isRelatedToCurrentDocument,
|
||||
...props
|
||||
}: RelationsInputProps) => {
|
||||
const [textValue, setTextValue] = React.useState<string | undefined>('');
|
||||
const [searchParams, setSearchParams] = React.useState({
|
||||
_q: '',
|
||||
page: 1,
|
||||
@ -492,7 +497,7 @@ const RelationsInput = ({
|
||||
const { toggleNotification } = useNotification();
|
||||
const { currentDocumentMeta } = useDocumentContext('RelationsInput');
|
||||
const { formatMessage } = useIntl();
|
||||
const fieldRef = useFocusInputField<HTMLInputElement>(name);
|
||||
|
||||
const field = useField<RelationsFormValue>(name);
|
||||
|
||||
const searchParamsDebounced = useDebounce(searchParams, 300);
|
||||
@ -540,10 +545,6 @@ const RelationsInput = ({
|
||||
currentDocumentMeta.params,
|
||||
]);
|
||||
|
||||
const handleSearch = async (search: string) => {
|
||||
setSearchParams((s) => ({ ...s, _q: search, page: 1 }));
|
||||
};
|
||||
|
||||
const hasNextPage = data?.pagination ? data.pagination.page < data.pagination.pageCount : false;
|
||||
|
||||
const options = data?.results ?? [];
|
||||
@ -582,18 +583,6 @@ const RelationsInput = ({
|
||||
onChange(relation);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || !data.pagination) {
|
||||
return;
|
||||
} else if (data.pagination.page < data.pagination.pageCount) {
|
||||
setSearchParams((s) => ({ ...s, page: s.page + 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setTextValue('');
|
||||
}, [field.value]);
|
||||
|
||||
const relation = {
|
||||
collectionType: COLLECTION_TYPES,
|
||||
// @ts-expect-error – targetModel does exist on the attribute. But it's not typed.
|
||||
@ -602,25 +591,127 @@ const RelationsInput = ({
|
||||
params: currentDocumentMeta.params,
|
||||
} as DocumentMeta;
|
||||
|
||||
const { componentUID } = useComponent('RelationsField', ({ uid }) => ({
|
||||
componentUID: uid,
|
||||
}));
|
||||
const {
|
||||
permissions = [],
|
||||
isLoading: isLoadingPermissions,
|
||||
error,
|
||||
} = useRBAC(
|
||||
PERMISSIONS.map((action) => ({
|
||||
action,
|
||||
subject: relation.model,
|
||||
}))
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Flex alignItems="center" height="100%" justifyContent="center">
|
||||
<EmptyStateLayout
|
||||
icon={<WarningCircle width="16rem" />}
|
||||
content={formatMessage({
|
||||
id: 'anErrorOccurred',
|
||||
defaultMessage: 'Whoops! Something went wrong. Please, try again.',
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field.Root error={field.error} hint={hint} name={name} required={required}>
|
||||
<Field.Label action={labelAction}>{label}</Field.Label>
|
||||
<RelationModalRenderer>
|
||||
{({ dispatch }) => (
|
||||
<Combobox
|
||||
ref={fieldRef}
|
||||
creatable="visible"
|
||||
createMessage={() =>
|
||||
formatMessage({
|
||||
id: getTranslation('relation.create'),
|
||||
defaultMessage: 'Create a relation',
|
||||
})
|
||||
}
|
||||
onCreateOption={() => {
|
||||
<DocumentRBAC permissions={permissions} model={relation.model}>
|
||||
<RelationModalWithContext
|
||||
relation={relation}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingPermissions={isLoadingPermissions}
|
||||
isLoadingSearchRelations={isLoading}
|
||||
handleChange={handleChange}
|
||||
setSearchParams={setSearchParams}
|
||||
data={data}
|
||||
mainField={mainField}
|
||||
fieldValue={field.value}
|
||||
{...props}
|
||||
/>
|
||||
</DocumentRBAC>
|
||||
<Field.Error />
|
||||
<Field.Hint />
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
interface RelationModalWithContextProps
|
||||
extends Omit<RelationsInputProps, 'onChange' | 'label' | 'model' | 'isRelatedToCurrentDocument'> {
|
||||
relation: DocumentMeta;
|
||||
hasNextPage: boolean;
|
||||
isLoadingSearchRelations: boolean;
|
||||
isLoadingPermissions: boolean;
|
||||
handleChange: (relationId?: string) => void;
|
||||
data?: FindAvailable.Response;
|
||||
fieldValue?: RelationsFormValue;
|
||||
setSearchParams: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
_q: string;
|
||||
page: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
const RelationModalWithContext = ({
|
||||
relation,
|
||||
name,
|
||||
placeholder,
|
||||
hasNextPage,
|
||||
isLoadingSearchRelations,
|
||||
isLoadingPermissions,
|
||||
handleChange,
|
||||
mainField,
|
||||
setSearchParams,
|
||||
fieldValue,
|
||||
data,
|
||||
...props
|
||||
}: RelationModalWithContextProps) => {
|
||||
const [textValue, setTextValue] = React.useState<string | undefined>('');
|
||||
const { formatMessage } = useIntl();
|
||||
const canCreate = useDocumentRBAC('RelationModalWrapper', (state) => state.canCreate);
|
||||
const fieldRef = useFocusInputField<HTMLInputElement>(name);
|
||||
const { componentUID } = useComponent('RelationsField', ({ uid }) => ({
|
||||
componentUID: uid,
|
||||
}));
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || !data.pagination) {
|
||||
return;
|
||||
} else if (data.pagination.page < data.pagination.pageCount) {
|
||||
setSearchParams((s) => ({ ...s, page: s.page + 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const options = data?.results ?? [];
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setTextValue('');
|
||||
}, [fieldValue]);
|
||||
|
||||
const handleSearch = async (search: string) => {
|
||||
setSearchParams((s) => ({ ...s, _q: search, page: 1 }));
|
||||
};
|
||||
return (
|
||||
<RelationModalRenderer>
|
||||
{({ dispatch }) => (
|
||||
<Combobox
|
||||
ref={fieldRef}
|
||||
creatable="visible"
|
||||
creatableDisabled={!canCreate}
|
||||
createMessage={() =>
|
||||
formatMessage({
|
||||
id: getTranslation('relation.create'),
|
||||
defaultMessage: 'Create a relation',
|
||||
})
|
||||
}
|
||||
onCreateOption={() => {
|
||||
if (canCreate) {
|
||||
dispatch({
|
||||
type: 'GO_TO_RELATION',
|
||||
payload: {
|
||||
@ -630,64 +721,62 @@ const RelationsInput = ({
|
||||
fieldToConnectUID: componentUID,
|
||||
},
|
||||
});
|
||||
}}
|
||||
creatableStartIcon={<Plus fill="neutral500" />}
|
||||
name={name}
|
||||
autocomplete="list"
|
||||
placeholder={
|
||||
placeholder ||
|
||||
formatMessage({
|
||||
id: getTranslation('relation.add'),
|
||||
defaultMessage: 'Add relation',
|
||||
})
|
||||
}
|
||||
hasMoreItems={hasNextPage}
|
||||
loading={isLoading}
|
||||
onOpenChange={() => {
|
||||
handleSearch(textValue ?? '');
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
formatMessage({
|
||||
id: getTranslation('relation.notAvailable'),
|
||||
defaultMessage: 'No relations available',
|
||||
})
|
||||
}
|
||||
loadingMessage={formatMessage({
|
||||
id: getTranslation('relation.isLoading'),
|
||||
defaultMessage: 'Relations are loading',
|
||||
})}
|
||||
onLoadMore={handleLoadMore}
|
||||
textValue={textValue}
|
||||
onChange={handleChange}
|
||||
onTextValueChange={(text) => {
|
||||
setTextValue(text);
|
||||
}}
|
||||
onInputChange={(event) => {
|
||||
handleSearch(event.currentTarget.value);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const textValue = getRelationLabel(opt, mainField);
|
||||
}}
|
||||
creatableStartIcon={<Plus fill="neutral500" />}
|
||||
name={name}
|
||||
autocomplete="list"
|
||||
placeholder={
|
||||
placeholder ||
|
||||
formatMessage({
|
||||
id: getTranslation('relation.add'),
|
||||
defaultMessage: 'Add relation',
|
||||
})
|
||||
}
|
||||
hasMoreItems={hasNextPage}
|
||||
loading={isLoadingSearchRelations || isLoadingPermissions}
|
||||
onOpenChange={() => {
|
||||
handleSearch(textValue ?? '');
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
formatMessage({
|
||||
id: getTranslation('relation.notAvailable'),
|
||||
defaultMessage: 'No relations available',
|
||||
})
|
||||
}
|
||||
loadingMessage={formatMessage({
|
||||
id: getTranslation('relation.isLoading'),
|
||||
defaultMessage: 'Relations are loading',
|
||||
})}
|
||||
onLoadMore={handleLoadMore}
|
||||
textValue={textValue}
|
||||
onChange={handleChange}
|
||||
onTextValueChange={(text) => {
|
||||
setTextValue(text);
|
||||
}}
|
||||
onInputChange={(event) => {
|
||||
handleSearch(event.currentTarget.value);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{options?.map((opt) => {
|
||||
const textValue = getRelationLabel(opt, mainField);
|
||||
|
||||
return (
|
||||
<ComboboxOption key={opt.id} value={opt.id.toString()} textValue={textValue}>
|
||||
<Flex gap={2} justifyContent="space-between">
|
||||
<Flex gap={2}>
|
||||
<LinkIcon fill="neutral500" />
|
||||
<Typography ellipsis>{textValue}</Typography>
|
||||
</Flex>
|
||||
{opt.status ? <DocumentStatus status={opt.status} /> : null}
|
||||
return (
|
||||
<ComboboxOption key={opt.id} value={opt.id.toString()} textValue={textValue}>
|
||||
<Flex gap={2} justifyContent="space-between">
|
||||
<Flex gap={2}>
|
||||
<LinkIcon fill="neutral500" />
|
||||
<Typography ellipsis>{textValue}</Typography>
|
||||
</Flex>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</Combobox>
|
||||
)}
|
||||
</RelationModalRenderer>
|
||||
<Field.Error />
|
||||
<Field.Hint />
|
||||
</Field.Root>
|
||||
{opt.status ? <DocumentStatus status={opt.status} /> : null}
|
||||
</Flex>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</Combobox>
|
||||
)}
|
||||
</RelationModalRenderer>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { RenderOptions, fireEvent, render as renderRTL, screen } from '@tests/utils';
|
||||
import { RenderOptions, fireEvent, render as renderRTL, screen, waitFor } from '@tests/utils';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { RelationsInput, RelationsFieldProps } from '../Relations';
|
||||
|
||||
const render = ({
|
||||
initialEntries,
|
||||
...props
|
||||
}: Partial<RelationsFieldProps> & Pick<RenderOptions, 'initialEntries'> = {}) =>
|
||||
const render = (
|
||||
{
|
||||
initialEntries,
|
||||
...props
|
||||
}: Partial<RelationsFieldProps> & Pick<RenderOptions, 'initialEntries'> = { initialEntries: [] }
|
||||
) =>
|
||||
renderRTL(
|
||||
<RelationsInput
|
||||
attribute={{
|
||||
@ -55,13 +57,26 @@ describe('Relations', () => {
|
||||
});
|
||||
|
||||
it('should render the relations list when there is data from the API', async () => {
|
||||
render();
|
||||
render({
|
||||
initialEntries: ['/content-manager/collection-types/api::address.address/12345'],
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('relations')).toBe(screen.getByRole('combobox'));
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
|
||||
// Wait for the loading state to finish
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Relations are loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('relations (3)')).toBe(screen.getByRole('combobox'));
|
||||
// Wait for the combobox to be rendered with the correct label
|
||||
await screen.findByLabelText(/relations/);
|
||||
|
||||
// Wait for the list items to be rendered
|
||||
const listItems = await screen.findAllByRole('listitem');
|
||||
expect(listItems).toHaveLength(3);
|
||||
|
||||
// Wait for the combobox to be updated with the count
|
||||
await screen.findByLabelText(/relations \(3\)/);
|
||||
|
||||
// Check for the relation buttons
|
||||
expect(screen.getByRole('button', { name: 'Relation entity 1' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Relation entity 2' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Relation entity 3' })).toBeInTheDocument();
|
||||
@ -70,7 +85,9 @@ describe('Relations', () => {
|
||||
it('should be disabled when the prop is passed', async () => {
|
||||
render({ disabled: true });
|
||||
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
@ -78,7 +95,20 @@ describe('Relations', () => {
|
||||
it('should render a hint when the prop is passed', async () => {
|
||||
render({ hint: 'This is a hint' });
|
||||
|
||||
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
|
||||
// Wait for the combobox to be rendered with the correct label
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/relations/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the loading state to finish
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Relations are loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for the list items to be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(screen.getByText('This is a hint')).toBeInTheDocument();
|
||||
});
|
||||
@ -92,56 +122,84 @@ describe('Relations', () => {
|
||||
it.todo('should disconnect a relation');
|
||||
|
||||
describe.skip('Accessibility', () => {
|
||||
it('should have have description text', () => {
|
||||
const { getByText } = render();
|
||||
it('should have have description text', async () => {
|
||||
render();
|
||||
|
||||
expect(getByText('Press spacebar to grab and re-order')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Press spacebar to grab and re-order')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the live text when an item has been grabbed', async () => {
|
||||
const { getByText, getAllByText } = render();
|
||||
render();
|
||||
|
||||
const [draggedItem] = getAllByText('Drag');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Drag')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const [draggedItem] = screen.getAllByText('Drag');
|
||||
|
||||
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
|
||||
|
||||
expect(
|
||||
getByText(/Press up and down arrow to change position, Spacebar to drop, Escape to cancel/)
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Press up and down arrow to change position, Spacebar to drop, Escape to cancel/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should change the live text when an item has been moved', () => {
|
||||
const { getByText, getAllByText } = render();
|
||||
it('should change the live text when an item has been moved', async () => {
|
||||
render();
|
||||
|
||||
const [draggedItem] = getAllByText('Drag');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Drag')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const [draggedItem] = screen.getAllByText('Drag');
|
||||
|
||||
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
|
||||
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
expect(getByText(/New position in list/)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/New position in list/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should change the live text when an item has been dropped', () => {
|
||||
const { getByText, getAllByText } = render();
|
||||
it('should change the live text when an item has been dropped', async () => {
|
||||
render();
|
||||
|
||||
const [draggedItem] = getAllByText('Drag');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Drag')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const [draggedItem] = screen.getAllByText('Drag');
|
||||
|
||||
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
|
||||
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
|
||||
|
||||
expect(getByText(/Final position in list/)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Final position in list/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should change the live text after the reordering interaction has been cancelled', () => {
|
||||
const { getAllByText, getByText } = render();
|
||||
it('should change the live text after the reordering interaction has been cancelled', async () => {
|
||||
render();
|
||||
|
||||
const [draggedItem] = getAllByText('Drag');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Drag')).toHaveLength(3);
|
||||
});
|
||||
|
||||
const [draggedItem] = screen.getAllByText('Drag');
|
||||
|
||||
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
|
||||
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
|
||||
|
||||
expect(getByText(/Re-order cancelled/)).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Re-order cancelled/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useForm, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import { Box, BoxProps, Flex, Grid } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { styled } from 'styled-components';
|
||||
@ -52,6 +53,8 @@ interface FormLayoutProps extends Pick<EditLayout, 'layout'> {
|
||||
const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const modelUid = document.schema?.uid;
|
||||
const fieldValues = useForm('Fields', (state) => state.values);
|
||||
const rulesEngine = createRulesEngine();
|
||||
|
||||
const getLabel = (name: string, label: string) => {
|
||||
return formatMessage({
|
||||
@ -66,6 +69,15 @@ const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps)
|
||||
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||
const [row] = panel;
|
||||
const [field] = row;
|
||||
const attribute = document.schema?.attributes[field.name];
|
||||
const condition = attribute?.conditions?.visible;
|
||||
|
||||
if (condition) {
|
||||
const isVisible = rulesEngine.evaluate(condition, fieldValues);
|
||||
if (!isVisible) {
|
||||
return null; // Skip rendering the dynamic zone if the condition is not met
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid.Root key={field.name} gap={4}>
|
||||
@ -83,28 +95,45 @@ const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps)
|
||||
return (
|
||||
<Box key={index} {...(hasBackground && panelStyles)}>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{panel.map((row, gridRowIndex) => (
|
||||
<ResponsiveGridRoot key={gridRowIndex} gap={4}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
return (
|
||||
<ResponsiveGridItem
|
||||
col={size}
|
||||
key={field.name}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<InputRenderer
|
||||
{...field}
|
||||
label={getLabel(field.name, field.label)}
|
||||
document={document}
|
||||
/>
|
||||
</ResponsiveGridItem>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridRoot>
|
||||
))}
|
||||
{panel.map((row, gridRowIndex) => {
|
||||
const visibleFields = row.filter(({ name }) => {
|
||||
const attribute = document.schema?.attributes[name];
|
||||
const condition = attribute?.conditions?.visible;
|
||||
|
||||
if (condition) {
|
||||
return rulesEngine.evaluate(condition, fieldValues);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null; // Skip rendering the entire grid row
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveGridRoot key={gridRowIndex} gap={4}>
|
||||
{visibleFields.map(({ size, ...field }) => {
|
||||
return (
|
||||
<ResponsiveGridItem
|
||||
col={size}
|
||||
key={field.name}
|
||||
s={12}
|
||||
xs={12}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
<InputRenderer
|
||||
{...field}
|
||||
label={getLabel(field.name, field.label)}
|
||||
document={document}
|
||||
/>
|
||||
</ResponsiveGridItem>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridRoot>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||
import { generateNKeysBetween } from 'fractional-indexing';
|
||||
import pipe from 'lodash/fp/pipe';
|
||||
|
||||
@ -210,11 +211,147 @@ const transformDocument =
|
||||
return transformations(document);
|
||||
};
|
||||
|
||||
type HandleOptions = {
|
||||
schema?: Schema.ContentType | Schema.Component;
|
||||
initialValues?: AnyData;
|
||||
components?: Record<string, Schema.Component>;
|
||||
};
|
||||
|
||||
type RemovedFieldPath = string;
|
||||
|
||||
/**
|
||||
* Removes values from the data object if their corresponding attribute has a
|
||||
* visibility condition that evaluates to false.
|
||||
*
|
||||
* @param {object} schema - The content type schema (with attributes).
|
||||
* @param {object} data - The data object to filter based on visibility.
|
||||
* @returns {object} A new data object with only visible fields retained.
|
||||
*/
|
||||
const handleInvisibleAttributes = (
|
||||
data: AnyData,
|
||||
{ schema, initialValues = {}, components = {} }: HandleOptions,
|
||||
path: string[] = [],
|
||||
removedAttributes: RemovedFieldPath[] = []
|
||||
): {
|
||||
data: AnyData;
|
||||
removedAttributes: RemovedFieldPath[];
|
||||
} => {
|
||||
if (!schema?.attributes) return { data, removedAttributes };
|
||||
|
||||
const rulesEngine = createRulesEngine();
|
||||
const result: AnyData = {};
|
||||
|
||||
for (const [attrName, attrDef] of Object.entries(schema.attributes)) {
|
||||
const fullPath = [...path, attrName].join('.');
|
||||
const condition = attrDef?.conditions?.visible;
|
||||
const isVisible = condition ? rulesEngine.evaluate(condition, { ...data, ...result }) : true;
|
||||
|
||||
if (!isVisible) {
|
||||
removedAttributes.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const userProvided = Object.prototype.hasOwnProperty.call(data, attrName);
|
||||
const currentValue = userProvided ? data[attrName] : undefined;
|
||||
const initialValue = initialValues?.[attrName];
|
||||
|
||||
// 🔹 Handle components
|
||||
if (attrDef.type === 'component') {
|
||||
const compSchema = components[attrDef.component];
|
||||
const value = currentValue ?? initialValue;
|
||||
|
||||
if (!value) {
|
||||
result[attrName] = attrDef.repeatable ? [] : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attrDef.repeatable && Array.isArray(value)) {
|
||||
result[attrName] = value.map(
|
||||
(item, index) =>
|
||||
handleInvisibleAttributes(
|
||||
item,
|
||||
{
|
||||
schema: compSchema,
|
||||
initialValues: initialValue?.[index] ?? {},
|
||||
components,
|
||||
},
|
||||
[...path, `${attrName}[${index}]`],
|
||||
removedAttributes
|
||||
).data
|
||||
);
|
||||
} else {
|
||||
result[attrName] = handleInvisibleAttributes(
|
||||
value,
|
||||
{
|
||||
schema: compSchema,
|
||||
initialValues: initialValue ?? {},
|
||||
components,
|
||||
},
|
||||
[...path, attrName],
|
||||
removedAttributes
|
||||
).data;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🔸 Handle dynamic zones
|
||||
if (attrDef.type === 'dynamiczone') {
|
||||
if (!Array.isArray(currentValue)) {
|
||||
result[attrName] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
result[attrName] = currentValue.map((dzItem, index) => {
|
||||
const compUID = dzItem?.__component;
|
||||
const compSchema = components[compUID];
|
||||
|
||||
const cleaned = handleInvisibleAttributes(
|
||||
dzItem,
|
||||
{
|
||||
schema: compSchema,
|
||||
initialValues: initialValue?.[index] ?? {},
|
||||
components,
|
||||
},
|
||||
[...path, `${attrName}[${index}]`],
|
||||
removedAttributes
|
||||
).data;
|
||||
|
||||
return {
|
||||
__component: compUID,
|
||||
...cleaned,
|
||||
};
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🟡 Handle scalar/primitive
|
||||
if (currentValue !== undefined) {
|
||||
result[attrName] = currentValue;
|
||||
} else if (initialValue !== undefined) {
|
||||
result[attrName] = initialValue;
|
||||
} else {
|
||||
if (attrName === 'id' || attrName === 'documentId') {
|
||||
// If the attribute is 'id', we don't want to set it to null, as it should not be removed.
|
||||
continue;
|
||||
}
|
||||
result[attrName] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: result,
|
||||
removedAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
removeProhibitedFields,
|
||||
prepareRelations,
|
||||
prepareTempKeys,
|
||||
removeFieldsThatDontExistOnSchema,
|
||||
transformDocument,
|
||||
handleInvisibleAttributes,
|
||||
};
|
||||
export type { AnyData };
|
||||
|
@ -205,5 +205,10 @@
|
||||
"history.restore.confirm.title": "Êtes-vous sûr de vouloir restaurer cette version ?",
|
||||
"history.restore.confirm.message": "{isDraft, select, true {Le contenu restauré écrasera votre brouillon.} other {Le contenu restauré ne sera pas publié, il écrasera le brouillon et sera sauvegardé en tant que changement en attente de publication. Vous pourrez publier les changements à tout moment.}}",
|
||||
"history.restore.success.title": "Version restaurée.",
|
||||
"history.restore.success.message": "Le contenu de la version restaurée n'a pas encore été publié."
|
||||
"history.restore.success.message": "Le contenu de la version restaurée n'a pas encore été publié.",
|
||||
"widget.last-edited.title": "Dernières entrées éditées",
|
||||
"widget.last-edited.single-type": "Types uniques",
|
||||
"widget.last-edited.no-data": "Aucune entrée éditée",
|
||||
"widget.last-published.title": "Dernières entrées publiées",
|
||||
"widget.last-published.no-data": "Aucune entrée publiée"
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ type AnySchema =
|
||||
|
||||
interface ValidationOptions {
|
||||
status: 'draft' | 'published' | null;
|
||||
removedAttributes?: string[];
|
||||
}
|
||||
|
||||
const arrayValidator = (attribute: Schema['attributes'][string], options: ValidationOptions) => ({
|
||||
@ -46,7 +47,7 @@ const arrayValidator = (attribute: Schema['attributes'][string], options: Valida
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
/**
|
||||
* TODO: should we create a Map to store these based on the hash of the schema?
|
||||
*/
|
||||
@ -55,15 +56,36 @@ const createYupSchema = (
|
||||
components: ComponentsDictionary = {},
|
||||
options: ValidationOptions = { status: null }
|
||||
): yup.ObjectSchema<any> => {
|
||||
const createModelSchema = (attributes: Schema['attributes']): yup.ObjectSchema<any> =>
|
||||
const createModelSchema = (
|
||||
attributes: Schema['attributes'],
|
||||
removedAttributes: string[] = []
|
||||
): yup.ObjectSchema<any> =>
|
||||
yup
|
||||
.object()
|
||||
.shape(
|
||||
Object.entries(attributes).reduce<ObjectShape>((acc, [name, attribute]) => {
|
||||
const getNestedPathsForAttribute = (removed: string[], attrName: string): string[] => {
|
||||
const prefix = `${attrName}.`;
|
||||
const bracketRegex = new RegExp(`^${escapeRegex(attrName)}\\[\\d+\\]\\.`);
|
||||
|
||||
return removed
|
||||
.filter((p) => p.startsWith(prefix) || bracketRegex.test(p))
|
||||
.map((p) =>
|
||||
p.startsWith(prefix) ? p.slice(prefix.length) : p.replace(bracketRegex, '')
|
||||
);
|
||||
};
|
||||
|
||||
if (DOCUMENT_META_FIELDS.includes(name)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (removedAttributes?.includes(name)) {
|
||||
// If the attribute is not visible, we don't want to validate it
|
||||
return acc;
|
||||
}
|
||||
|
||||
const nestedRemoved = getNestedPathsForAttribute(removedAttributes, name);
|
||||
|
||||
/**
|
||||
* These validations won't apply to every attribute
|
||||
* and that's okay, in that case we just return the
|
||||
@ -89,13 +111,13 @@ const createYupSchema = (
|
||||
return {
|
||||
...acc,
|
||||
[name]: transformSchema(
|
||||
yup.array().of(createModelSchema(attributes).nullable(false))
|
||||
yup.array().of(createModelSchema(attributes, nestedRemoved).nullable(false))
|
||||
).test(arrayValidator(attribute, options)),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...acc,
|
||||
[name]: transformSchema(createModelSchema(attributes).nullable()),
|
||||
[name]: transformSchema(createModelSchema(attributes, nestedRemoved).nullable()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -120,7 +142,7 @@ const createYupSchema = (
|
||||
return validation;
|
||||
}
|
||||
|
||||
return validation.concat(createModelSchema(attributes));
|
||||
return validation.concat(createModelSchema(attributes, nestedRemoved));
|
||||
}
|
||||
) as unknown as yup.ObjectSchema<any>
|
||||
)
|
||||
@ -171,7 +193,7 @@ const createYupSchema = (
|
||||
*/
|
||||
.default(null);
|
||||
|
||||
return createModelSchema(attributes);
|
||||
return createModelSchema(attributes, options.removedAttributes);
|
||||
};
|
||||
|
||||
const createAttributeSchema = (
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/content-manager",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "A powerful UI to easily manage your data.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -67,10 +67,10 @@
|
||||
"@radix-ui/react-toolbar": "1.0.4",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"codemirror5": "npm:codemirror@^5.65.11",
|
||||
"date-fns": "2.30.0",
|
||||
"fractional-indexing": "3.2.0",
|
||||
@ -105,8 +105,8 @@
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/database": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/database": "5.17.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/lodash": "^4.14.191",
|
||||
|
@ -35,8 +35,8 @@ const sanitizeMainField = (model: any, mainField: any, userAbility: any) => {
|
||||
const canReadMainField = permissionChecker.can.read(null, mainField);
|
||||
|
||||
if (!isMainFieldListable || !canReadMainField) {
|
||||
// Default to 'id' if the actual main field shouldn't be displayed
|
||||
return 'id';
|
||||
// Default to 'documentId' if the actual main field shouldn't be displayed
|
||||
return 'documentId';
|
||||
}
|
||||
|
||||
// Edge cases
|
||||
|
@ -285,7 +285,9 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
||||
const otherStatus = await this.getManyAvailableStatus(uid, document.localizations);
|
||||
|
||||
document.localizations = document.localizations.map((d) => {
|
||||
const status = otherStatus.find((s) => s.documentId === d.documentId);
|
||||
const status = otherStatus.find(
|
||||
(s) => s.documentId === d.documentId && s.locale === d.locale
|
||||
);
|
||||
return {
|
||||
...d,
|
||||
status: this.getStatus(d, status ? [status] : []),
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/content-releases",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Strapi plugin for organizing and releasing content",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -59,11 +59,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@strapi/database": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/database": "5.17.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"date-fns": "2.30.0",
|
||||
"date-fns-tz": "2.0.1",
|
||||
"formik": "2.4.5",
|
||||
@ -75,9 +75,9 @@
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/admin-test-utils": "5.16.0",
|
||||
"@strapi/content-manager": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/admin-test-utils": "5.17.0",
|
||||
"@strapi/content-manager": "5.17.0",
|
||||
"@testing-library/dom": "10.1.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
|
@ -0,0 +1,40 @@
|
||||
import { Button, Tooltip } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
interface ApplyConditionButtonProps {
|
||||
disabled?: boolean;
|
||||
tooltipMessage?: string;
|
||||
onClick?: () => void;
|
||||
marginTop?: number;
|
||||
}
|
||||
|
||||
export const ApplyConditionButton = ({
|
||||
disabled,
|
||||
tooltipMessage,
|
||||
onClick,
|
||||
marginTop = 4,
|
||||
}: ApplyConditionButtonProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
marginTop={marginTop}
|
||||
fullWidth={true}
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
startIcon={<span aria-hidden>+</span>}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.apply',
|
||||
defaultMessage: 'Apply condition',
|
||||
})}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipMessage) {
|
||||
return <Tooltip description={tooltipMessage}>{button}</Tooltip>;
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { forwardRef, memo, useState } from 'react';
|
||||
|
||||
import { Box, Flex, IconButton, Typography, Link } from '@strapi/design-system';
|
||||
import { ConfirmDialog } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Flex, IconButton, Typography, Link, Badge, Dialog } from '@strapi/design-system';
|
||||
import { ChevronDown, Drag, Lock, Pencil, Trash } from '@strapi/icons';
|
||||
import get from 'lodash/get';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
@ -9,6 +10,7 @@ import { Link as NavLink } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { Curve } from '../icons/Curve';
|
||||
import { checkDependentRows } from '../utils/conditions';
|
||||
import { getAttributeDisplayedType } from '../utils/getAttributeDisplayedType';
|
||||
import { getRelationType } from '../utils/getRelationType';
|
||||
import { getTrad } from '../utils/getTrad';
|
||||
@ -102,6 +104,7 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
||||
const { onOpenModalEditField, onOpenModalEditCustomField } = useFormModalNavigation();
|
||||
|
||||
const { formatMessage } = useIntl();
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const isDeleted = item.status === 'REMOVED';
|
||||
|
||||
@ -115,6 +118,33 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
||||
|
||||
const src = 'target' in item && item.target ? 'relation' : ico;
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const dependentRows = checkDependentRows(contentTypes, item.name);
|
||||
if (dependentRows.length > 0) {
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
removeAttribute({
|
||||
forTarget: type.modelType,
|
||||
targetUid: type.uid,
|
||||
attributeToRemoveName: item.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
removeAttribute({
|
||||
forTarget: type.modelType,
|
||||
targetUid: type.uid,
|
||||
attributeToRemoveName: item.name,
|
||||
});
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMorph) {
|
||||
return;
|
||||
@ -217,6 +247,9 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
||||
repeatable={'repeatable' in item && item.repeatable}
|
||||
multiple={'multiple' in item && item.multiple}
|
||||
/>
|
||||
{'conditions' in item &&
|
||||
item.conditions &&
|
||||
Object.keys(item.conditions).length > 0 && <Badge margin={4}>conditional</Badge>}
|
||||
{item.type === 'relation' && (
|
||||
<>
|
||||
({getRelationType(item.relation, item.targetAttribute)})
|
||||
@ -298,14 +331,7 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeAttribute({
|
||||
forTarget: type.modelType,
|
||||
targetUid: type.uid,
|
||||
attributeToRemoveName: item.name,
|
||||
});
|
||||
}}
|
||||
onClick={handleDelete}
|
||||
label={`${formatMessage({
|
||||
id: 'global.delete',
|
||||
defaultMessage: 'Delete',
|
||||
@ -315,6 +341,32 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
||||
>
|
||||
<Trash />
|
||||
</IconButton>
|
||||
<Dialog.Root open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<ConfirmDialog onConfirm={handleConfirmDelete} onCancel={handleCancelDelete}>
|
||||
<Box>
|
||||
<Typography>
|
||||
{formatMessage({
|
||||
id: getTrad(
|
||||
'popUpWarning.bodyMessage.delete-attribute-with-conditions'
|
||||
),
|
||||
defaultMessage:
|
||||
'The following fields have conditions that depend on this field: ',
|
||||
})}
|
||||
<Typography fontWeight="bold">
|
||||
{checkDependentRows(contentTypes, item.name)
|
||||
.map(({ attribute }) => attribute)
|
||||
.join(', ')}
|
||||
</Typography>
|
||||
{formatMessage({
|
||||
id: getTrad(
|
||||
'popUpWarning.bodyMessage.delete-attribute-with-conditions-end'
|
||||
),
|
||||
defaultMessage: '. Are you sure you want to delete it?',
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ConfirmDialog>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
) : (
|
||||
<Flex padding={2}>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -2,11 +2,8 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Layouts } from '@strapi/admin/strapi-admin';
|
||||
import { DesignSystemProvider } from '@strapi/design-system';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@strapi/admin/strapi-admin/test';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { useDataManager } from '../../DataManager/useDataManager';
|
||||
import { ContentTypeBuilderNav } from '../ContentTypeBuilderNav';
|
||||
@ -58,19 +55,11 @@ jest.mock('../../DataManager/useDataManager.ts', () => {
|
||||
|
||||
const mockedUseDataManager = jest.mocked(useDataManager);
|
||||
|
||||
const makeApp = () => {
|
||||
return (
|
||||
<IntlProvider messages={{}} defaultLocale="en" textComponent="span" locale="en">
|
||||
<DesignSystemProvider>
|
||||
<MemoryRouter>
|
||||
<Layouts.Root sideNav={<ContentTypeBuilderNav />}>
|
||||
<div />
|
||||
</Layouts.Root>
|
||||
</MemoryRouter>
|
||||
</DesignSystemProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
const App = (
|
||||
<Layouts.Root sideNav={<ContentTypeBuilderNav />}>
|
||||
<div />
|
||||
</Layouts.Root>
|
||||
);
|
||||
|
||||
describe('<ContentTypeBuilderNav />', () => {
|
||||
beforeEach(() => {
|
||||
@ -98,7 +87,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
});
|
||||
|
||||
it('renders and matches the snapshot', () => {
|
||||
const App = makeApp();
|
||||
const { container } = render(App);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
@ -106,7 +94,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
describe('save button', () => {
|
||||
it('should render the save button', () => {
|
||||
const App = makeApp();
|
||||
const { getByRole } = render(App);
|
||||
|
||||
const saveButton = getByRole('button', { name: /save/i });
|
||||
@ -114,8 +101,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
});
|
||||
|
||||
it('should be disabled when there are no changes', () => {
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
@ -137,8 +122,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
it.each([true, false])(
|
||||
'should be disabled when not in development mode & isModified=%s',
|
||||
(isModified) => {
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@ -159,8 +142,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
);
|
||||
|
||||
it('should be enabled when there are changes', () => {
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@ -183,7 +164,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
describe('unde redo discardAllChanges', () => {
|
||||
it('should render the undo item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -194,7 +174,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should render the redo item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -205,7 +185,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should render the discard item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -216,7 +196,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should render the undo item as disabled if not in development mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -241,7 +220,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should render the redo item as disabled if not in development mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -266,7 +244,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should render the discard item as disabled if not in development mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -298,7 +275,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
},
|
||||
])('should enable the undo item when there are changes to undo', async (opts) => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -331,7 +307,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
},
|
||||
])('should enable the redo item when there are changes to redo', async (opts) => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -364,7 +339,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
},
|
||||
])('should enable the discard item when there are changes to discard', async (opts) => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
mockedUseDataManager.mockImplementation(
|
||||
() =>
|
||||
@ -390,7 +364,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should open the discard confirmation modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
const { getByRole } = render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -406,7 +380,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should close the discard confirmation modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -427,7 +401,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should call discardChanges after confirm', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -444,7 +418,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should call undoHandler', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -459,7 +433,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
it('should call redoHandler', async () => {
|
||||
const user = userEvent.setup();
|
||||
const App = makeApp();
|
||||
|
||||
render(App);
|
||||
|
||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||
@ -476,7 +450,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
|
||||
describe('search', () => {
|
||||
it('should render the search input', () => {
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument();
|
||||
@ -485,7 +458,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
it('Should call search.onChange when the input value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: /search/i });
|
||||
@ -498,7 +470,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
||||
it('Should clear the search input when the clear button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const App = makeApp();
|
||||
render(App);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: /search/i });
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
useAppInfo,
|
||||
useFetchClient,
|
||||
useAuth,
|
||||
adminApi,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@ -181,7 +182,9 @@ const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
|
||||
|
||||
// Make sure the server has restarted
|
||||
await serverRestartWatcher();
|
||||
|
||||
// Invalidate the guided tour meta query cache
|
||||
// @ts-expect-error typescript is unable to infer the tag types defined on adminApi
|
||||
dispatch(adminApi.util.invalidateTags(['GuidedTourMeta']));
|
||||
// refetch and update initial state after the data has been saved
|
||||
await getDataRef.current();
|
||||
// Update the app's permissions
|
||||
|
@ -1,7 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStrapiApp, useTracking, useNotification } from '@strapi/admin/strapi-admin';
|
||||
import { Button, Divider, Flex, Modal, Tabs } from '@strapi/design-system';
|
||||
import {
|
||||
useStrapiApp,
|
||||
useTracking,
|
||||
useNotification,
|
||||
ConfirmDialog,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import { Button, Divider, Flex, Modal, Tabs, Box, Typography, Dialog } from '@strapi/design-system';
|
||||
import get from 'lodash/get';
|
||||
import has from 'lodash/has';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@ -44,6 +50,7 @@ import { SingularName } from '../SingularName';
|
||||
import { TabForm } from '../TabForm';
|
||||
import { TextareaEnum } from '../TextareaEnum';
|
||||
|
||||
import { ConditionForm } from './attributes/ConditionForm';
|
||||
import { forms } from './forms/forms';
|
||||
import { actions, initialState, type State as FormModalState } from './reducer';
|
||||
import { canEditContentType } from './utils/canEditContentType';
|
||||
@ -129,6 +136,54 @@ export const FormModal = () => {
|
||||
|
||||
const type = forTarget === 'component' ? components[targetUid] : contentTypes[targetUid];
|
||||
|
||||
const [showWarningDialog, setShowWarningDialog] = useState(false);
|
||||
const [pendingSubmit, setPendingSubmit] = useState<any>(null);
|
||||
|
||||
const checkFieldNameChanges = () => {
|
||||
// Only check when editing an attribute
|
||||
if (actionType !== 'edit' || modalType !== 'attribute') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldName = initialData.name;
|
||||
const oldEnum = initialData.enum;
|
||||
const newEnum = modifiedData.enum;
|
||||
|
||||
// Get all attributes from the content type schema
|
||||
const contentTypeAttributes = type?.attributes || [];
|
||||
|
||||
// Find all fields that reference this field in their conditions
|
||||
const referencedFields = contentTypeAttributes.filter((attr: any) => {
|
||||
if (!attr.conditions) return false;
|
||||
|
||||
const condition = attr.conditions.visible;
|
||||
if (!condition) return false;
|
||||
|
||||
const [[, conditions]] = Object.entries(condition);
|
||||
const [fieldVar, value] = conditions as [{ var: string }, any];
|
||||
|
||||
// Check if this condition references our field
|
||||
if (fieldVar.var !== oldName) return false;
|
||||
|
||||
// If it's an enum field, also check if the value is being deleted/changed
|
||||
if (oldEnum && newEnum) {
|
||||
const deletedOrChangedValues = oldEnum.filter(
|
||||
(oldValue: string) => !newEnum.includes(oldValue)
|
||||
);
|
||||
return deletedOrChangedValues.includes(value);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// If any fields reference this field, return them
|
||||
if (referencedFields.length > 0) {
|
||||
return referencedFields;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
const collectionTypesForRelation = sortedContentTypesList.filter(
|
||||
@ -400,7 +455,7 @@ export const FormModal = () => {
|
||||
({
|
||||
target: { name, value, type, ...rest },
|
||||
}: {
|
||||
target: { name: string; value: string; type: string };
|
||||
target: { name: string; value: string | string[]; type: string };
|
||||
}) => {
|
||||
const namesThatCanResetToNullValue = [
|
||||
'enumName',
|
||||
@ -416,6 +471,9 @@ export const FormModal = () => {
|
||||
|
||||
if (namesThatCanResetToNullValue.includes(name) && value === '') {
|
||||
val = null;
|
||||
} else if (name === 'enum') {
|
||||
// For enum values, ensure we're working with an array
|
||||
val = Array.isArray(value) ? value : [value];
|
||||
} else {
|
||||
val = value;
|
||||
}
|
||||
@ -451,9 +509,7 @@ export const FormModal = () => {
|
||||
[dispatch, formErrors]
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent, shouldContinue = isCreating) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitForm = async (e: React.SyntheticEvent, shouldContinue = isCreating) => {
|
||||
try {
|
||||
await checkFormValidity();
|
||||
|
||||
@ -822,6 +878,20 @@ export const FormModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.SyntheticEvent, shouldContinue = isCreating) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check for field name changes when clicking Finish
|
||||
const referencedFields = checkFieldNameChanges();
|
||||
if (referencedFields) {
|
||||
setPendingSubmit({ e, shouldContinue });
|
||||
setShowWarningDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await submitForm(e, shouldContinue);
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
const confirm = window.confirm(
|
||||
@ -929,6 +999,7 @@ export const FormModal = () => {
|
||||
'text-plural': PluralName,
|
||||
'text-singular': SingularName,
|
||||
'textarea-enum': TextareaEnum,
|
||||
'condition-form': ConditionForm,
|
||||
...inputsFromPlugins,
|
||||
},
|
||||
componentToCreate,
|
||||
@ -942,6 +1013,7 @@ export const FormModal = () => {
|
||||
isCreating,
|
||||
targetUid,
|
||||
forTarget,
|
||||
contentTypeSchema: type,
|
||||
};
|
||||
|
||||
const advancedForm = formToDisplay.advanced({
|
||||
@ -992,6 +1064,79 @@ export const FormModal = () => {
|
||||
return (
|
||||
<Modal.Root open={isOpen} onOpenChange={handleClosed}>
|
||||
<Modal.Content>
|
||||
<Dialog.Root open={showWarningDialog} onOpenChange={setShowWarningDialog}>
|
||||
<Dialog.Trigger />
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (pendingSubmit) {
|
||||
const { e, shouldContinue } = pendingSubmit;
|
||||
setShowWarningDialog(false);
|
||||
setPendingSubmit(null);
|
||||
submitForm(e, shouldContinue);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowWarningDialog(false);
|
||||
setPendingSubmit(null);
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const referencedFields = checkFieldNameChanges();
|
||||
if (!referencedFields) return null;
|
||||
|
||||
const fieldNames = referencedFields.map((field: any) => field.name).join(', ');
|
||||
const isEnum = initialData.enum && modifiedData.enum;
|
||||
|
||||
if (isEnum) {
|
||||
const oldEnum = initialData.enum;
|
||||
const newEnum = modifiedData.enum;
|
||||
const deletedOrChangedValues = oldEnum.filter(
|
||||
(value: string) => !newEnum.includes(value)
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.enum-change-warning',
|
||||
defaultMessage:
|
||||
'The following fields have conditions that depend on this field: ',
|
||||
})}
|
||||
<Typography fontWeight="bold">{fieldNames}</Typography>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.enum-change-warning-values',
|
||||
defaultMessage: '. Changing or removing the enum values ',
|
||||
})}
|
||||
<Typography fontWeight="bold">{deletedOrChangedValues.join(', ')}</Typography>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.enum-change-warning-end',
|
||||
defaultMessage: ' will break these conditions. Do you want to proceed?',
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.field-change-warning',
|
||||
defaultMessage:
|
||||
'The following fields have conditions that depend on this field: ',
|
||||
})}
|
||||
<Typography fontWeight="bold">{fieldNames}</Typography>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.field-change-warning-end',
|
||||
defaultMessage:
|
||||
'. Renaming it will break these conditions. Do you want to proceed?',
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</ConfirmDialog>
|
||||
</Dialog.Root>
|
||||
<FormModalHeader
|
||||
actionType={actionType}
|
||||
attributeName={attributeName}
|
||||
|
@ -0,0 +1,408 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createRulesEngine, ConfirmDialog, type Condition } from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Typography,
|
||||
Field,
|
||||
SingleSelect,
|
||||
SingleSelectOption,
|
||||
Dialog,
|
||||
} from '@strapi/design-system';
|
||||
import { Trash } from '@strapi/icons';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { styled } from 'styled-components';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { AttributeIcon } from '../../../components/AttributeIcon';
|
||||
import { getTrad } from '../../../utils/getTrad';
|
||||
import { ApplyConditionButton } from '../../ApplyConditionButton';
|
||||
|
||||
const SmallAttributeIcon = styled(AttributeIcon)`
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ConditionFormProps {
|
||||
name: string;
|
||||
value: any;
|
||||
onChange: (e: { target: { name: string; value: any } }) => void;
|
||||
onDelete: () => void;
|
||||
attributeName?: string;
|
||||
conditionFields?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
enum?: string[];
|
||||
}>;
|
||||
allAttributes?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface JsonLogicValue {
|
||||
visible?: {
|
||||
[key: string]: [{ var: string }, any];
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalValue {
|
||||
dependsOn: string;
|
||||
operator: 'is' | 'isNot';
|
||||
value: string | boolean;
|
||||
action: 'show' | 'hide';
|
||||
}
|
||||
|
||||
const convertFromJsonLogic = (jsonLogic: JsonLogicValue): LocalValue => {
|
||||
if (!jsonLogic?.visible) {
|
||||
return {
|
||||
dependsOn: '',
|
||||
operator: 'is',
|
||||
value: '',
|
||||
action: 'show',
|
||||
};
|
||||
}
|
||||
|
||||
const [[operator, conditions]] = Object.entries(jsonLogic.visible);
|
||||
const [fieldVar, value] = conditions as [{ var: string }, any];
|
||||
|
||||
return {
|
||||
dependsOn: fieldVar.var,
|
||||
operator: operator === '==' ? 'is' : 'isNot',
|
||||
value: value,
|
||||
action: operator === '==' ? 'show' : 'hide',
|
||||
};
|
||||
};
|
||||
|
||||
const convertToJsonLogic = (value: LocalValue): JsonLogicValue | null => {
|
||||
if (!value.dependsOn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rulesEngine = createRulesEngine();
|
||||
const condition: Condition = {
|
||||
dependsOn: value.dependsOn,
|
||||
operator: value.operator,
|
||||
value: value.value,
|
||||
};
|
||||
|
||||
try {
|
||||
rulesEngine.validate(condition);
|
||||
const action = value.action === 'show' ? '==' : '!=';
|
||||
return {
|
||||
visible: {
|
||||
[action]: [{ var: value.dependsOn }, value.value],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const ConditionForm = ({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onDelete,
|
||||
attributeName,
|
||||
conditionFields = [],
|
||||
}: ConditionFormProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const [localValue, setLocalValue] = React.useState<LocalValue>(convertFromJsonLogic(value));
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const hasCondition = Boolean(value?.visible);
|
||||
|
||||
// Add safety check for conditionFields
|
||||
if (!Array.isArray(conditionFields)) {
|
||||
conditionFields = [];
|
||||
}
|
||||
|
||||
const selectedField = conditionFields.find((field) => field.name === localValue.dependsOn);
|
||||
const isEnumField = selectedField?.type === 'enumeration';
|
||||
|
||||
// Helper to update localValue and propagate JSON Logic
|
||||
const updateCondition = (updatedValue: LocalValue) => {
|
||||
setLocalValue(updatedValue);
|
||||
const rulesEngine = createRulesEngine();
|
||||
const condition: Condition = {
|
||||
dependsOn: updatedValue.dependsOn,
|
||||
operator: updatedValue.operator,
|
||||
value: updatedValue.value,
|
||||
};
|
||||
try {
|
||||
rulesEngine.validate(condition);
|
||||
const action = updatedValue.action === 'show' ? '==' : '!=';
|
||||
const jsonLogic = updatedValue.dependsOn
|
||||
? {
|
||||
visible: {
|
||||
[action]: [{ var: updatedValue.dependsOn }, updatedValue.value],
|
||||
},
|
||||
}
|
||||
: null;
|
||||
if (jsonLogic) {
|
||||
onChange({
|
||||
target: {
|
||||
name,
|
||||
value: jsonLogic,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Do nothing if invalid
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCondition = () => {
|
||||
const initialValue: LocalValue = {
|
||||
dependsOn: '',
|
||||
operator: 'is',
|
||||
value: '',
|
||||
action: 'show',
|
||||
};
|
||||
setLocalValue(initialValue);
|
||||
onChange({
|
||||
target: {
|
||||
name,
|
||||
value: convertToJsonLogic(initialValue),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setLocalValue({
|
||||
dependsOn: '',
|
||||
operator: 'is',
|
||||
value: '',
|
||||
action: 'show',
|
||||
});
|
||||
onChange({
|
||||
target: {
|
||||
name,
|
||||
value: null,
|
||||
},
|
||||
});
|
||||
onDelete();
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string | number) => {
|
||||
const newValue = fieldName?.toString() || '';
|
||||
const field = conditionFields.find((f) => f.name === newValue);
|
||||
const isNewFieldEnum = field?.type === 'enumeration';
|
||||
const updatedValue: LocalValue = {
|
||||
...localValue,
|
||||
dependsOn: newValue,
|
||||
value: newValue ? (isNewFieldEnum ? '' : false) : localValue.value,
|
||||
};
|
||||
updateCondition(updatedValue);
|
||||
};
|
||||
|
||||
const handleOperatorChange = (operator: string | number) => {
|
||||
const newValue = operator?.toString() || 'is';
|
||||
const updatedValue: LocalValue = {
|
||||
...localValue,
|
||||
operator: newValue as 'is' | 'isNot',
|
||||
};
|
||||
updateCondition(updatedValue);
|
||||
};
|
||||
|
||||
const handleValueChange = (newValue: string | number) => {
|
||||
const value = isEnumField ? newValue?.toString() : newValue?.toString() === 'true';
|
||||
const updatedValue: LocalValue = { ...localValue, value };
|
||||
updateCondition(updatedValue);
|
||||
};
|
||||
|
||||
const handleActionChange = (action: string | number) => {
|
||||
const newValue = action?.toString() || 'show';
|
||||
const updatedValue: LocalValue = {
|
||||
...localValue,
|
||||
action: newValue as 'show' | 'hide',
|
||||
};
|
||||
updateCondition(updatedValue);
|
||||
};
|
||||
|
||||
if (!hasCondition) {
|
||||
return (
|
||||
<Box padding={4} margin={4} hasRadius background="neutral0" borderColor="neutral200">
|
||||
<ApplyConditionButton onClick={handleApplyCondition} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={2}>
|
||||
<Box
|
||||
background="neutral0"
|
||||
hasRadius
|
||||
borderColor="neutral200"
|
||||
borderWidth={0.5}
|
||||
borderStyle="solid"
|
||||
>
|
||||
<Flex justifyContent="space-between" alignItems="center" padding={4}>
|
||||
<Typography variant="sigma" textColor="neutral800">
|
||||
{formatMessage(
|
||||
{
|
||||
id: getTrad('form.attribute.condition.title'),
|
||||
defaultMessage: 'Condition for {name}',
|
||||
},
|
||||
{
|
||||
name: <strong>{attributeName}</strong>,
|
||||
}
|
||||
)}
|
||||
</Typography>
|
||||
<Dialog.Root open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<Dialog.Trigger>
|
||||
<IconButton label="Delete">
|
||||
<Trash />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<ConfirmDialog onConfirm={handleDelete}>
|
||||
{formatMessage({
|
||||
id: getTrad('popUpWarning.bodyMessage.delete-condition'),
|
||||
defaultMessage: 'Are you sure you want to delete this condition?',
|
||||
})}
|
||||
</ConfirmDialog>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
|
||||
<Box background="neutral100" padding={4}>
|
||||
<Box paddingBottom={2}>
|
||||
<Typography
|
||||
variant="sigma"
|
||||
textColor="neutral600"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: 1 }}
|
||||
>
|
||||
{formatMessage({ id: getTrad('form.attribute.condition.if'), defaultMessage: 'IF' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Flex gap={4}>
|
||||
<Box minWidth={0} flex={1}>
|
||||
<Field.Root name={`${name}.field`}>
|
||||
<SingleSelect
|
||||
value={localValue.dependsOn}
|
||||
onChange={handleFieldChange}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.attribute.condition.field'),
|
||||
defaultMessage: 'field',
|
||||
})}
|
||||
>
|
||||
{conditionFields.map((field) => (
|
||||
<SingleSelectOption key={field.name} value={field.name}>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<SmallAttributeIcon type={field.type} />
|
||||
<span>{field.name}</span>
|
||||
</Flex>
|
||||
</SingleSelectOption>
|
||||
))}
|
||||
</SingleSelect>
|
||||
</Field.Root>
|
||||
</Box>
|
||||
<Box minWidth={0} flex={1}>
|
||||
<Field.Root name={`${name}.operator`}>
|
||||
<SingleSelect
|
||||
value={localValue.operator}
|
||||
onChange={handleOperatorChange}
|
||||
disabled={!localValue.dependsOn}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.attribute.condition.operator'),
|
||||
defaultMessage: 'condition',
|
||||
})}
|
||||
>
|
||||
<SingleSelectOption value="is">
|
||||
{formatMessage({
|
||||
id: getTrad('form.attribute.condition.operator.is'),
|
||||
defaultMessage: 'is',
|
||||
})}
|
||||
</SingleSelectOption>
|
||||
<SingleSelectOption value="isNot">
|
||||
{formatMessage({
|
||||
id: getTrad('form.attribute.condition.operator.isNot'),
|
||||
defaultMessage: 'is not',
|
||||
})}
|
||||
</SingleSelectOption>
|
||||
</SingleSelect>
|
||||
</Field.Root>
|
||||
</Box>
|
||||
<Box minWidth={0} flex={1}>
|
||||
<Field.Root name={`${name}.value`}>
|
||||
<SingleSelect
|
||||
value={localValue.value?.toString() || ''}
|
||||
onChange={handleValueChange}
|
||||
disabled={!localValue.dependsOn}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.attribute.condition.value'),
|
||||
defaultMessage: 'value',
|
||||
})}
|
||||
>
|
||||
{isEnumField && selectedField?.enum ? (
|
||||
selectedField.enum.map((enumValue) => (
|
||||
<SingleSelectOption key={enumValue} value={enumValue}>
|
||||
{enumValue}
|
||||
</SingleSelectOption>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<SingleSelectOption value="true">
|
||||
{formatMessage({
|
||||
id: getTrad('form.attribute.condition.value.true'),
|
||||
defaultMessage: 'true',
|
||||
})}
|
||||
</SingleSelectOption>
|
||||
<SingleSelectOption value="false">
|
||||
{formatMessage({
|
||||
id: getTrad('form.attribute.condition.value.false'),
|
||||
defaultMessage: 'false',
|
||||
})}
|
||||
</SingleSelectOption>
|
||||
</>
|
||||
)}
|
||||
</SingleSelect>
|
||||
</Field.Root>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box background="neutral100" padding={4}>
|
||||
<Box paddingBottom={4}>
|
||||
<Typography
|
||||
variant="sigma"
|
||||
textColor="neutral600"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: 1 }}
|
||||
>
|
||||
{formatMessage({
|
||||
id: getTrad('form.attribute.condition.then'),
|
||||
defaultMessage: 'THEN',
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box paddingBottom={4}>
|
||||
<Field.Root name={`${name}.action`}>
|
||||
<SingleSelect
|
||||
value={localValue.action}
|
||||
onChange={handleActionChange}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.attribute.condition.action'),
|
||||
defaultMessage: 'action',
|
||||
})}
|
||||
>
|
||||
<SingleSelectOption value="show">
|
||||
Show <span style={{ fontWeight: 'bold' }}>{attributeName || name}</span>
|
||||
</SingleSelectOption>
|
||||
<SingleSelectOption value="hide">
|
||||
Hide <span style={{ fontWeight: 'bold' }}>{attributeName || name}</span>
|
||||
</SingleSelectOption>
|
||||
</SingleSelect>
|
||||
</Field.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -5,6 +5,31 @@ import { attributeOptions } from './attributeOptions';
|
||||
|
||||
type DataType = 'biginteger' | 'string' | 'integer' | 'float' | 'decimal';
|
||||
|
||||
const conditionSection = {
|
||||
sectionTitle: {
|
||||
id: getTrad('form.attribute.condition.title'),
|
||||
defaultMessage: 'Condition',
|
||||
},
|
||||
intlLabel: {
|
||||
id: getTrad('form.attribute.condition.description'),
|
||||
defaultMessage:
|
||||
'Toggle field settings depending on the value of another boolean or enumeration field.',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'conditions',
|
||||
type: 'condition-form',
|
||||
intlLabel: {
|
||||
id: getTrad('form.attribute.condition.label'),
|
||||
defaultMessage: 'Conditions',
|
||||
},
|
||||
validations: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const advancedForm = {
|
||||
blocks() {
|
||||
return {
|
||||
@ -16,6 +41,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -60,6 +86,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -97,6 +124,7 @@ export const advancedForm = {
|
||||
maxComponentsAttribute,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -110,6 +138,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -136,6 +165,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.unique, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -149,6 +179,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.max, attributeOptions.min],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -164,7 +195,6 @@ export const advancedForm = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
sectionTitle: {
|
||||
id: 'global.settings',
|
||||
@ -178,6 +208,7 @@ export const advancedForm = {
|
||||
attributeOptions.private,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -234,7 +265,6 @@ export const advancedForm = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
sectionTitle: {
|
||||
id: 'global.settings',
|
||||
@ -242,6 +272,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -255,6 +286,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -284,6 +316,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.required, attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -321,6 +354,7 @@ export const advancedForm = {
|
||||
attributeOptions.private,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -328,7 +362,6 @@ export const advancedForm = {
|
||||
return {
|
||||
sections: [
|
||||
{ sectionTitle: null, items: [attributeOptions.default] },
|
||||
|
||||
{
|
||||
sectionTitle: {
|
||||
id: 'global.settings',
|
||||
@ -341,6 +374,7 @@ export const advancedForm = {
|
||||
attributeOptions.private,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -354,6 +388,7 @@ export const advancedForm = {
|
||||
},
|
||||
items: [attributeOptions.private],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -373,6 +408,7 @@ export const advancedForm = {
|
||||
attributeOptions.private,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -380,7 +416,6 @@ export const advancedForm = {
|
||||
return {
|
||||
sections: [
|
||||
{ sectionTitle: null, items: [attributeOptions.default, attributeOptions.regex] },
|
||||
|
||||
{
|
||||
sectionTitle: {
|
||||
id: 'global.settings',
|
||||
@ -394,6 +429,7 @@ export const advancedForm = {
|
||||
attributeOptions.private,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
@ -406,7 +442,6 @@ export const advancedForm = {
|
||||
{ ...attributeOptions.default, disabled: Boolean(data.targetField), type: 'text' },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
sectionTitle: {
|
||||
id: 'global.settings',
|
||||
@ -420,6 +455,7 @@ export const advancedForm = {
|
||||
attributeOptions.regex,
|
||||
],
|
||||
},
|
||||
conditionSection,
|
||||
],
|
||||
};
|
||||
},
|
||||
|
@ -162,11 +162,14 @@ export const forms = {
|
||||
...rest,
|
||||
});
|
||||
|
||||
let injected = false;
|
||||
|
||||
const sections = baseForm.reduce((acc: Array<any>, current: any) => {
|
||||
if (current.sectionTitle === null) {
|
||||
if (current.sectionTitle === null || injected) {
|
||||
acc.push(current);
|
||||
} else {
|
||||
acc.push({ ...current, items: [...current.items, ...itemsToAdd] });
|
||||
injected = true;
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
@ -44,10 +44,38 @@ interface InputOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CustomInputProps<TAttribute extends Schema.Attribute.AnyAttribute>
|
||||
extends Omit<GenericInputProps<TAttribute>, 'customInputs'> {
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
interface CustomInputProps<
|
||||
TAttribute extends Schema.Attribute.AnyAttribute = Schema.Attribute.AnyAttribute,
|
||||
> {
|
||||
attribute?: TAttribute;
|
||||
autoComplete?: string;
|
||||
description?: TranslationMessage;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
hint?: string | React.JSX.Element | (string | React.JSX.Element)[];
|
||||
intlLabel: TranslationMessage;
|
||||
labelAction?: React.ReactNode;
|
||||
name: string;
|
||||
onChange: (
|
||||
payload: {
|
||||
target: {
|
||||
name: string;
|
||||
value: Schema.Attribute.Value<TAttribute>;
|
||||
type?: string;
|
||||
};
|
||||
},
|
||||
shouldSetInitialValue?: boolean
|
||||
) => void;
|
||||
onDelete?: () => void;
|
||||
options?: InputOption[];
|
||||
placeholder?: TranslationMessage;
|
||||
required?: boolean;
|
||||
step?: number;
|
||||
type: string;
|
||||
value?: Schema.Attribute.Value<TAttribute>;
|
||||
autoFocus?: boolean;
|
||||
attributeName?: string;
|
||||
conditionFields?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface GenericInputProps<
|
||||
@ -72,6 +100,7 @@ interface GenericInputProps<
|
||||
},
|
||||
shouldSetInitialValue?: boolean
|
||||
) => void;
|
||||
onDelete?: () => void;
|
||||
options?: InputOption[];
|
||||
placeholder?: TranslationMessage;
|
||||
required?: boolean;
|
||||
@ -81,6 +110,8 @@ interface GenericInputProps<
|
||||
value?: Schema.Attribute.Value<TAttribute>;
|
||||
isNullable?: boolean;
|
||||
autoFocus?: boolean;
|
||||
attributeName?: string;
|
||||
conditionFields?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const GenericInput = ({
|
||||
@ -93,6 +124,7 @@ const GenericInput = ({
|
||||
error,
|
||||
name,
|
||||
onChange,
|
||||
onDelete,
|
||||
options = [],
|
||||
placeholder,
|
||||
required,
|
||||
@ -102,6 +134,8 @@ const GenericInput = ({
|
||||
isNullable,
|
||||
autoFocus,
|
||||
attribute,
|
||||
attributeName,
|
||||
conditionFields,
|
||||
...rest
|
||||
}: GenericInputProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -195,12 +229,15 @@ const GenericInput = ({
|
||||
error={errorMessage || ''}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
onDelete={onDelete}
|
||||
options={options}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
attributeName={attributeName}
|
||||
conditionFields={conditionFields}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Box, Grid, Typography } from '@strapi/design-system';
|
||||
import { Box, Grid, Typography, Button, Tooltip } from '@strapi/design-system';
|
||||
import get from 'lodash/get';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { formatCondition, getAvailableConditionFields } from '../utils/conditions';
|
||||
|
||||
import { GenericInput } from './GenericInputs';
|
||||
|
||||
interface TabFormProps {
|
||||
@ -29,7 +31,6 @@ export const TabForm = ({
|
||||
if (section.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={sectionIndex}>
|
||||
{section.sectionTitle && (
|
||||
@ -39,6 +40,12 @@ export const TabForm = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.intlLabel && (
|
||||
<Typography variant="pi" textColor="neutral600">
|
||||
{formatMessage(section.intlLabel)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Grid.Root gap={4}>
|
||||
{section.items.map((input: any, i: number) => {
|
||||
const key = `${sectionIndex}.${i}`;
|
||||
@ -89,6 +96,98 @@ export const TabForm = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Special handling for 'condition-form'
|
||||
if (input.type === 'condition-form') {
|
||||
const currentCondition = get(modifiedData, input.name);
|
||||
|
||||
// Get all attributes from the content type schema
|
||||
const contentTypeAttributes =
|
||||
genericInputProps.contentTypeSchema?.attributes || [];
|
||||
|
||||
if (!genericInputProps.contentTypeSchema) {
|
||||
console.warn('contentTypeSchema is undefined, skipping condition form');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter for boolean and enumeration fields only, excluding the current field
|
||||
const availableFields = getAvailableConditionFields(
|
||||
contentTypeAttributes,
|
||||
modifiedData.name
|
||||
);
|
||||
|
||||
const noFieldsMessage = formatMessage({
|
||||
id: 'form.attribute.condition.no-fields',
|
||||
defaultMessage:
|
||||
'No boolean or enumeration fields available to set conditions on.',
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid.Item
|
||||
col={input.size || 12}
|
||||
key={input.name || key}
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
>
|
||||
{!currentCondition || Object.keys(currentCondition).length === 0 ? (
|
||||
<Box>
|
||||
{currentCondition && Object.keys(currentCondition).length > 0 && (
|
||||
<Typography variant="sigma" textColor="neutral800" marginBottom={2}>
|
||||
{formatCondition(
|
||||
currentCondition,
|
||||
availableFields,
|
||||
genericInputProps.attributeName || modifiedData.name
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip description={noFieldsMessage}>
|
||||
<Button
|
||||
marginTop={
|
||||
currentCondition && Object.keys(currentCondition).length > 0 ? 0 : 4
|
||||
}
|
||||
fullWidth={true}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
target: {
|
||||
name: input.name,
|
||||
value: { visible: { '==': [{ var: '' }, ''] } },
|
||||
},
|
||||
});
|
||||
}}
|
||||
startIcon={<span aria-hidden>+</span>}
|
||||
disabled={availableFields.length === 0}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'form.attribute.condition.apply',
|
||||
defaultMessage: 'Apply condition',
|
||||
})}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<GenericInput
|
||||
{...input}
|
||||
{...genericInputProps}
|
||||
error={errorId}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
autoFocus={i === 0}
|
||||
attributeName={modifiedData.name}
|
||||
conditionFields={availableFields}
|
||||
onDelete={() => {
|
||||
onChange({
|
||||
target: {
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Grid.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering for all other input types
|
||||
return (
|
||||
<Grid.Item
|
||||
col={input.size || 6}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Flex, Typography } from '@strapi/design-system';
|
||||
import { unstable_tours } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Flex, Typography } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { getTrad } from '../../utils/getTrad';
|
||||
@ -13,6 +14,10 @@ export const EmptyState = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<unstable_tours.contentTypeBuilder.Introduction>
|
||||
{/* Invisible Anchor */}
|
||||
<Box paddingTop={5} />
|
||||
</unstable_tours.contentTypeBuilder.Introduction>
|
||||
<Flex justifyContent="center" alignItems="center" height="100%" direction="column">
|
||||
<Typography variant="alpha">{pluginName}</Typography>
|
||||
<Typography variant="delta">
|
||||
|
@ -122,6 +122,27 @@
|
||||
"form.attribute.text.option.long-text.description": "Best for descriptions, biography. Exact search is disabled.",
|
||||
"form.attribute.text.option.short-text": "Short text",
|
||||
"form.attribute.text.option.short-text.description": "Best for titles, names, links (URL). It also enables exact search on the field.",
|
||||
"form.attribute.condition.title": "Condition",
|
||||
"form.attribute.condition.description": "Toggle field settings depending on the value of another boolean or enumeration field.",
|
||||
"form.attribute.condition.label": "Conditions",
|
||||
"form.attribute.condition.field": "Field",
|
||||
"form.attribute.condition.operator": "Operator",
|
||||
"form.attribute.condition.value": "Value",
|
||||
"form.attribute.condition.operator.is": "is",
|
||||
"form.attribute.condition.operator.isNot": "is not",
|
||||
"form.attribute.condition.value.true": "true",
|
||||
"form.attribute.condition.value.false": "false",
|
||||
"form.attribute.condition.apply": "Apply condition",
|
||||
"form.attribute.condition.then": "Then",
|
||||
"form.attribute.condition.action": "Action",
|
||||
"form.attribute.condition.action.show": "Show",
|
||||
"form.attribute.condition.action.hide": "Hide",
|
||||
"form.attribute.condition.no-fields": "No boolean or enumeration fields available to set conditions on.",
|
||||
"form.attribute.condition.enum-change-warning": "The following fields have conditions that depend on this field: {fieldNames}. Changing or removing the enum values {values} will break these conditions. Do you want to proceed?",
|
||||
"form.attribute.condition.enum-change-warning-values": ". Changing or removing the enum values ",
|
||||
"form.attribute.condition.enum-change-warning-end": " will break these conditions. Do you want to proceed?",
|
||||
"form.attribute.condition.field-change-warning": "The following fields have conditions that depend on this field: {fieldNames}. Renaming it will break these conditions. Do you want to proceed?",
|
||||
"form.attribute.condition.field-change-warning-end": ". Renaming it will break these conditions. Do you want to proceed?",
|
||||
"form.button.add-components-to-dynamiczone": "Add components to the zone",
|
||||
"form.button.add-field": "Add another field",
|
||||
"form.button.add-first-field-to-created-component": "Add first field to the component",
|
||||
@ -190,6 +211,9 @@
|
||||
"popUpWarning.draft-publish.message": "If you disable the Draft & publish, your drafts will be deleted.",
|
||||
"popUpWarning.draft-publish.second-message": "Are you sure you want to disable it?",
|
||||
"popUpWarning.discardAll.message": "Are you sure you want to discard all changes?",
|
||||
"popUpWarning.bodyMessage.delete-condition": "Are you sure you want to delete this condition?",
|
||||
"popUpWarning.bodyMessage.delete-attribute-with-conditions": "The following fields have conditions that depend on this field: ",
|
||||
"popUpWarning.bodyMessage.delete-attribute-with-conditions-end": ". Are you sure you want to delete it?",
|
||||
"prompt.unsaved": "Are you sure you want to leave? All your modifications will be lost.",
|
||||
"relation.attributeName.placeholder": "Ex: author, category, tag",
|
||||
"relation.manyToMany": "has and belongs to many",
|
||||
|
@ -0,0 +1,84 @@
|
||||
import type { AnyAttribute } from '../types';
|
||||
|
||||
interface DependentRow {
|
||||
contentTypeUid: string;
|
||||
contentType: string;
|
||||
attribute: string;
|
||||
}
|
||||
|
||||
export const checkDependentRows = (
|
||||
contentTypes: Record<string, any>,
|
||||
fieldName: string
|
||||
): DependentRow[] => {
|
||||
const dependentRows: DependentRow[] = [];
|
||||
|
||||
Object.entries(contentTypes).forEach(([contentTypeUid, contentType]: [string, any]) => {
|
||||
if (contentType.attributes) {
|
||||
// Handle both array and object formats of attributes
|
||||
const attributes = Array.isArray(contentType.attributes)
|
||||
? contentType.attributes.reduce((acc: Record<string, any>, attr: any, index: number) => {
|
||||
acc[index.toString()] = attr;
|
||||
return acc;
|
||||
}, {})
|
||||
: contentType.attributes;
|
||||
|
||||
Object.entries(attributes).forEach(([attrName, attr]: [string, any]) => {
|
||||
if (attr.conditions?.visible) {
|
||||
Object.entries(attr.conditions.visible).forEach(([, conditions]) => {
|
||||
const [fieldVar] = conditions as [{ var: string }, any];
|
||||
// Check if this condition references our field
|
||||
if (fieldVar && fieldVar.var === fieldName) {
|
||||
dependentRows.push({
|
||||
contentTypeUid,
|
||||
contentType: contentType.info.displayName,
|
||||
attribute: attr.name || attrName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return dependentRows;
|
||||
};
|
||||
|
||||
export const formatCondition = (
|
||||
condition: any,
|
||||
availableFields: Array<{ name: string; type: string }>,
|
||||
attributeName: string
|
||||
): string => {
|
||||
if (!condition?.visible) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [[operator, conditions]] = Object.entries(condition.visible);
|
||||
const [fieldVar, value] = conditions as [{ var: string }, any];
|
||||
|
||||
const dependsOnField = availableFields.find((field) => field.name === fieldVar.var);
|
||||
const dependsOnFieldName = dependsOnField ? dependsOnField.name : fieldVar.var;
|
||||
|
||||
const operatorText = operator === '==' ? 'is' : 'is not';
|
||||
const valueText = String(value);
|
||||
const actionText = operator === '==' ? 'Show' : 'Hide';
|
||||
|
||||
return `If ${dependsOnFieldName} ${operatorText} ${valueText}, then ${actionText} ${attributeName}`;
|
||||
};
|
||||
|
||||
export const getAvailableConditionFields = (
|
||||
attributes: AnyAttribute[],
|
||||
currentFieldName: string
|
||||
) => {
|
||||
return attributes
|
||||
.filter((attr) => {
|
||||
// Only include boolean and enum fields
|
||||
const isCorrectType = attr.type === 'boolean' || attr.type === 'enumeration';
|
||||
// Exclude the current field to prevent self-referential conditions
|
||||
const isNotCurrentField = attr.name !== currentFieldName;
|
||||
return isCorrectType && isNotCurrentField;
|
||||
})
|
||||
.map((attr) => ({
|
||||
name: attr.name,
|
||||
type: attr.type,
|
||||
enum: attr.type === 'enumeration' ? attr.enum : undefined,
|
||||
}));
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/content-type-builder",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Create and manage content types",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -66,10 +66,10 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@sindresorhus/slugify": "1.1.0",
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/generators": "5.16.0",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/generators": "5.17.0",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"date-fns": "2.30.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"immer": "9.0.21",
|
||||
@ -82,8 +82,8 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@testing-library/dom": "10.1.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
|
@ -175,6 +175,10 @@ const enumRefinement: z.SuperRefinement<{
|
||||
}
|
||||
};
|
||||
|
||||
const conditionSchema = z.object({
|
||||
visible: z.record(z.string(), z.array(z.any())),
|
||||
});
|
||||
|
||||
const basePropertiesSchema = z.object({
|
||||
type: z.enum([
|
||||
'string',
|
||||
@ -204,6 +208,9 @@ const basePropertiesSchema = z.object({
|
||||
configurable: z.boolean().nullish(),
|
||||
private: z.boolean().nullish(),
|
||||
pluginOptions: z.record(z.unknown()).optional(),
|
||||
conditions: z.preprocess((val) => {
|
||||
return val;
|
||||
}, conditionSchema.optional()),
|
||||
});
|
||||
|
||||
const maxLengthSchema = z.number().int().positive().optional();
|
||||
@ -229,6 +236,9 @@ const baseRelationSchema = z.object({
|
||||
configurable: z.boolean().nullish(),
|
||||
private: z.boolean().nullish(),
|
||||
pluginOptions: z.record(z.unknown()).optional(),
|
||||
conditions: z.preprocess((val) => {
|
||||
return val;
|
||||
}, conditionSchema.optional()),
|
||||
});
|
||||
|
||||
const oneToOneSchema = baseRelationSchema.extend({
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/core",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Core of Strapi",
|
||||
"homepage": "https://strapi.io",
|
||||
"bugs": {
|
||||
@ -56,14 +56,14 @@
|
||||
"@koa/cors": "5.0.0",
|
||||
"@koa/router": "12.0.2",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/database": "5.16.0",
|
||||
"@strapi/generators": "5.16.0",
|
||||
"@strapi/logger": "5.16.0",
|
||||
"@strapi/permissions": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/typescript-utils": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/database": "5.17.0",
|
||||
"@strapi/generators": "5.17.0",
|
||||
"@strapi/logger": "5.17.0",
|
||||
"@strapi/permissions": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@strapi/typescript-utils": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"boxen": "5.1.2",
|
||||
"chalk": "4.1.2",
|
||||
@ -82,6 +82,7 @@
|
||||
"http-errors": "2.0.0",
|
||||
"inquirer": "8.2.5",
|
||||
"is-docker": "2.2.1",
|
||||
"json-logic-js": "2.0.5",
|
||||
"koa": "2.16.1",
|
||||
"koa-body": "6.0.1",
|
||||
"koa-compose": "4.1.0",
|
||||
@ -116,6 +117,7 @@
|
||||
"@types/global-agent": "2.1.3",
|
||||
"@types/http-errors": "2.0.4",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/json-logic-js": "2.0.8",
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/koa-compress": "4.0.3",
|
||||
"@types/koa-session": "6.4.1",
|
||||
@ -126,9 +128,9 @@
|
||||
"@types/node": "18.19.24",
|
||||
"@types/node-schedule": "2.1.7",
|
||||
"@types/statuses": "2.0.1",
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"supertest": "6.3.3",
|
||||
"tsconfig": "5.16.0"
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -29,6 +29,7 @@ import createAuth from './services/auth';
|
||||
import createCustomFields from './services/custom-fields';
|
||||
import createContentAPI from './services/content-api';
|
||||
import getNumberOfDynamicZones from './services/utils/dynamic-zones';
|
||||
import getNumberOfConditionalFields from './services/utils/conditional-fields';
|
||||
import { FeaturesService, createFeaturesService } from './services/features';
|
||||
import { createDocumentService } from './services/document-service';
|
||||
|
||||
@ -301,6 +302,7 @@ class Strapi extends Container implements Core.Strapi {
|
||||
numberOfAllContentTypes: _.size(this.contentTypes), // TODO: V5: This event should be renamed numberOfContentTypes in V5 as the name is already taken to describe the number of content types using i18n.
|
||||
numberOfComponents: _.size(this.components),
|
||||
numberOfDynamicZones: getNumberOfDynamicZones(),
|
||||
numberOfConditionalFields: getNumberOfConditionalFields(),
|
||||
numberOfCustomControllers: Object.values<Core.Controller>(this.controllers).filter(
|
||||
// TODO: Fix this at the content API loader level to prevent future types issues
|
||||
(controller) => controller !== undefined && factories.isCustomController(controller)
|
||||
|
@ -18,7 +18,6 @@ const createEntriesService = (
|
||||
|
||||
async function createEntry(params = {} as any) {
|
||||
const { data, ...restParams } = await transformParamsDocumentId(uid, params);
|
||||
|
||||
const query = transformParamsToQuery(uid, pickSelectionParams(restParams) as any); // select / populate
|
||||
|
||||
// Validation
|
||||
|
@ -92,6 +92,40 @@ const localeToData: Transform = (contentType, params) => {
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy non-localized fields from an existing entry to a new entry being created
|
||||
* for a different locale of the same document. Returns a new object with the merged data.
|
||||
*/
|
||||
const copyNonLocalizedFields = async (
|
||||
contentType: Struct.SingleTypeSchema | Struct.CollectionTypeSchema,
|
||||
documentId: string,
|
||||
dataToCreate: Record<string, any>
|
||||
): Promise<Record<string, any>> => {
|
||||
// Check if this is a localized content type and if i18n plugin is available
|
||||
const i18nService = strapi.plugin('i18n')?.service('content-types');
|
||||
if (!i18nService?.isLocalizedContentType(contentType)) {
|
||||
return dataToCreate;
|
||||
}
|
||||
|
||||
// Find an existing entry for the same document to copy unlocalized fields from
|
||||
const existingEntry = await strapi.db.query(contentType.uid).findOne({
|
||||
where: { documentId },
|
||||
// Prefer published entry, but fall back to any entry
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
});
|
||||
|
||||
// If an entry exists in another locale, copy its non-localized fields
|
||||
if (existingEntry) {
|
||||
const mergedData = { ...dataToCreate };
|
||||
i18nService.fillNonLocalizedAttributes(mergedData, existingEntry, {
|
||||
model: contentType.uid,
|
||||
});
|
||||
return mergedData;
|
||||
}
|
||||
|
||||
return dataToCreate;
|
||||
};
|
||||
|
||||
const defaultLocaleCurry = curry(defaultLocale);
|
||||
const localeToLookupCurry = curry(localeToLookup);
|
||||
const multiLocaleToLookupCurry = curry(multiLocaleToLookup);
|
||||
@ -102,4 +136,5 @@ export {
|
||||
localeToLookupCurry as localeToLookup,
|
||||
localeToDataCurry as localeToData,
|
||||
multiLocaleToLookupCurry as multiLocaleToLookup,
|
||||
copyNonLocalizedFields,
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import type { UID } from '@strapi/types';
|
||||
import { wrapInTransaction, type RepositoryFactoryMethod } from './common';
|
||||
import * as DP from './draft-and-publish';
|
||||
import * as i18n from './internationalization';
|
||||
import { copyNonLocalizedFields } from './internationalization';
|
||||
import * as components from './components';
|
||||
|
||||
import { createEntriesService } from './entries';
|
||||
@ -240,9 +241,14 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (
|
||||
.findOne({ where: { documentId } });
|
||||
|
||||
if (documentExists) {
|
||||
const mergedData = await copyNonLocalizedFields(contentType, documentId, {
|
||||
...queryParams.data,
|
||||
documentId,
|
||||
});
|
||||
|
||||
updatedDraft = await entries.create({
|
||||
...queryParams,
|
||||
data: { ...queryParams.data, documentId },
|
||||
data: mergedData,
|
||||
});
|
||||
emitEvent('entry.create', updatedDraft);
|
||||
}
|
||||
|
@ -5,6 +5,7 @@
|
||||
|
||||
import { uniqBy, castArray, isNil, isArray, mergeWith } from 'lodash';
|
||||
import { has, prop, isObject, isEmpty } from 'lodash/fp';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
import strapiUtils from '@strapi/utils';
|
||||
import type { Modules, UID, Struct, Schema } from '@strapi/types';
|
||||
import { Validators, ValidatorMetas } from './validators';
|
||||
@ -268,6 +269,15 @@ const createAttributeValidator =
|
||||
(createOrUpdate: CreateOrUpdate) => (metas: ValidatorMetas, options: ValidatorContext) => {
|
||||
let validator = yup.mixed();
|
||||
|
||||
// If field is conditionally invisible, skip all validation for it
|
||||
if (metas.attr.conditions?.visible) {
|
||||
const isVisible = jsonLogic.apply(metas.attr.conditions.visible, metas.data);
|
||||
|
||||
if (!isVisible) {
|
||||
return yup.mixed().notRequired(); // Completely skip validation
|
||||
}
|
||||
}
|
||||
|
||||
if (isMediaAttribute(metas.attr)) {
|
||||
validator = yup.mixed();
|
||||
} else if (isScalarAttribute(metas.attr)) {
|
||||
@ -342,6 +352,7 @@ const createModelValidator =
|
||||
const metas = {
|
||||
attr: model.attributes[attributeName],
|
||||
updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
|
||||
data,
|
||||
model,
|
||||
entity,
|
||||
componentContext,
|
||||
|
@ -25,6 +25,7 @@ export interface ValidatorMetas<
|
||||
name: string;
|
||||
value: TValue;
|
||||
};
|
||||
data: Record<string, unknown>;
|
||||
componentContext?: ComponentContext;
|
||||
entity?: Modules.EntityValidator.Entity;
|
||||
}
|
||||
|
@ -0,0 +1,309 @@
|
||||
import type { Schema, UID } from '@strapi/types';
|
||||
import getNumberOfConditionalFields from '../conditional-fields';
|
||||
|
||||
const mockStrapi = {
|
||||
contentTypes: {} as Record<UID.ContentType, Schema.ContentType>,
|
||||
components: {} as Record<UID.Component, Schema.Component>,
|
||||
};
|
||||
|
||||
(global as any).strapi = mockStrapi;
|
||||
|
||||
describe('getNumberOfConditionalFields', () => {
|
||||
beforeEach(() => {
|
||||
mockStrapi.contentTypes = {};
|
||||
mockStrapi.components = {};
|
||||
});
|
||||
|
||||
describe('when no schemas exist', () => {
|
||||
it('should return 0 when there are no content types or components', () => {
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schemas have no conditional fields', () => {
|
||||
it('should return 0 for content types without conditional fields', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::article.article': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::article.article',
|
||||
kind: 'collectionType',
|
||||
modelName: 'article',
|
||||
globalId: 'Article',
|
||||
info: { singularName: 'article', pluralName: 'articles', displayName: 'Article' },
|
||||
options: {},
|
||||
attributes: {
|
||||
title: { type: 'string', required: true },
|
||||
content: { type: 'text' },
|
||||
publishedAt: { type: 'datetime' },
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for components without conditional fields', () => {
|
||||
mockStrapi.components = {
|
||||
'default.hero': {
|
||||
modelType: 'component',
|
||||
uid: 'default.hero',
|
||||
category: 'default',
|
||||
modelName: 'hero',
|
||||
globalId: 'ComponentDefaultHero',
|
||||
info: { displayName: 'Hero', icon: 'layer' },
|
||||
options: {},
|
||||
attributes: {
|
||||
title: { type: 'string' },
|
||||
subtitle: { type: 'string' },
|
||||
image: { type: 'media', multiple: false },
|
||||
},
|
||||
} as unknown as Schema.Component,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schemas have conditional fields', () => {
|
||||
it('should count conditional fields in content types correctly', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::article.article': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::article.article',
|
||||
kind: 'collectionType',
|
||||
modelName: 'article',
|
||||
globalId: 'Article',
|
||||
info: { singularName: 'article', pluralName: 'articles', displayName: 'Article' },
|
||||
options: {},
|
||||
attributes: {
|
||||
title: { type: 'string' },
|
||||
conditionalField: {
|
||||
type: 'string',
|
||||
conditions: { visible: true },
|
||||
},
|
||||
anotherConditionalField: {
|
||||
type: 'text',
|
||||
conditions: { required: { field: 'title', operator: 'isNotEmpty' } },
|
||||
},
|
||||
normalField: { type: 'string' },
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should count conditional fields in components correctly', () => {
|
||||
mockStrapi.components = {
|
||||
'default.hero': {
|
||||
modelType: 'component',
|
||||
uid: 'default.hero',
|
||||
category: 'default',
|
||||
modelName: 'hero',
|
||||
globalId: 'ComponentDefaultHero',
|
||||
info: { displayName: 'Hero', icon: 'layer' },
|
||||
options: {},
|
||||
attributes: {
|
||||
conditionalSubtitle: {
|
||||
type: 'string',
|
||||
conditions: { visible: { field: 'title', operator: 'isNotEmpty' } },
|
||||
},
|
||||
normalTitle: { type: 'string' },
|
||||
},
|
||||
} as unknown as Schema.Component,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should count conditional fields across multiple content types and components', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::article.article': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::article.article',
|
||||
kind: 'collectionType',
|
||||
modelName: 'article',
|
||||
globalId: 'Article',
|
||||
info: { singularName: 'article', pluralName: 'articles', displayName: 'Article' },
|
||||
options: {},
|
||||
attributes: {
|
||||
conditionalField: {
|
||||
type: 'string',
|
||||
conditions: { visible: true },
|
||||
},
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
'api::page.page': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::page.page',
|
||||
kind: 'collectionType',
|
||||
modelName: 'page',
|
||||
globalId: 'Page',
|
||||
info: { singularName: 'page', pluralName: 'pages', displayName: 'Page' },
|
||||
options: {},
|
||||
attributes: {
|
||||
anotherConditionalField: {
|
||||
type: 'text',
|
||||
conditions: { required: { field: 'title', value: 'test' } },
|
||||
},
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
mockStrapi.components = {
|
||||
'default.hero': {
|
||||
modelType: 'component',
|
||||
uid: 'default.hero',
|
||||
category: 'default',
|
||||
modelName: 'hero',
|
||||
globalId: 'ComponentDefaultHero',
|
||||
info: { displayName: 'Hero', icon: 'layer' },
|
||||
options: {},
|
||||
attributes: {
|
||||
conditionalSubtitle: {
|
||||
type: 'string',
|
||||
conditions: { visible: { field: 'title', operator: 'isNotEmpty' } },
|
||||
},
|
||||
},
|
||||
} as unknown as Schema.Component,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(3); // 2 from content types + 1 from component
|
||||
});
|
||||
});
|
||||
|
||||
describe('when conditions are invalid', () => {
|
||||
it('should ignore fields with non-object conditions', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::article.article': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::article.article',
|
||||
kind: 'collectionType',
|
||||
modelName: 'article',
|
||||
globalId: 'Article',
|
||||
info: { singularName: 'article', pluralName: 'articles', displayName: 'Article' },
|
||||
options: {},
|
||||
attributes: {
|
||||
fieldWithStringCondition: {
|
||||
type: 'string',
|
||||
conditions: 'some string',
|
||||
} as any,
|
||||
fieldWithNumberCondition: {
|
||||
type: 'string',
|
||||
conditions: 123,
|
||||
} as any,
|
||||
fieldWithNullCondition: {
|
||||
type: 'string',
|
||||
conditions: null,
|
||||
} as any,
|
||||
fieldWithUndefinedCondition: {
|
||||
type: 'string',
|
||||
conditions: undefined,
|
||||
} as any,
|
||||
validConditionalField: {
|
||||
type: 'string',
|
||||
conditions: { visible: true },
|
||||
},
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(1); // Only the valid conditional field
|
||||
});
|
||||
|
||||
it('should handle empty conditions object', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::article.article': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::article.article',
|
||||
kind: 'collectionType',
|
||||
modelName: 'article',
|
||||
globalId: 'Article',
|
||||
info: { singularName: 'article', pluralName: 'articles', displayName: 'Article' },
|
||||
options: {},
|
||||
attributes: {
|
||||
fieldWithEmptyConditions: {
|
||||
type: 'string',
|
||||
conditions: {},
|
||||
},
|
||||
validConditionalField: {
|
||||
type: 'string',
|
||||
conditions: { visible: true },
|
||||
},
|
||||
},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(2); // Both should be counted as they have object conditions
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle schemas with empty attributes', () => {
|
||||
mockStrapi.contentTypes = {
|
||||
'api::empty.empty': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::empty.empty',
|
||||
kind: 'collectionType',
|
||||
modelName: 'empty',
|
||||
globalId: 'Empty',
|
||||
info: { singularName: 'empty', pluralName: 'empties', displayName: 'Empty' },
|
||||
options: {},
|
||||
attributes: {},
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
mockStrapi.components = {
|
||||
'default.empty': {
|
||||
modelType: 'component',
|
||||
uid: 'default.empty',
|
||||
category: 'default',
|
||||
modelName: 'empty',
|
||||
globalId: 'ComponentDefaultEmpty',
|
||||
info: { displayName: 'Empty', icon: 'layer' },
|
||||
options: {},
|
||||
attributes: {},
|
||||
} as unknown as Schema.Component,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large numbers of conditional fields', () => {
|
||||
const attributes: Record<string, Schema.Attribute.AnyAttribute> = {};
|
||||
|
||||
// Create 100 conditional fields
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
attributes[`conditionalField${i}`] = {
|
||||
type: 'string',
|
||||
conditions: { visible: true },
|
||||
};
|
||||
}
|
||||
|
||||
mockStrapi.contentTypes = {
|
||||
'api::large.large': {
|
||||
modelType: 'contentType',
|
||||
uid: 'api::large.large',
|
||||
kind: 'collectionType',
|
||||
modelName: 'large',
|
||||
globalId: 'Large',
|
||||
info: { singularName: 'large', pluralName: 'larges', displayName: 'Large' },
|
||||
options: {},
|
||||
attributes,
|
||||
} as unknown as Schema.ContentType,
|
||||
};
|
||||
|
||||
const result = getNumberOfConditionalFields();
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
29
packages/core/core/src/services/utils/conditional-fields.ts
Normal file
29
packages/core/core/src/services/utils/conditional-fields.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { map, values, sumBy, pipe, flatMap } from 'lodash/fp';
|
||||
import type { Schema, UID } from '@strapi/types';
|
||||
|
||||
const getNumberOfConditionalFields = () => {
|
||||
const contentTypes: Record<UID.ContentType, Schema.ContentType> = strapi.contentTypes;
|
||||
const components: Record<UID.Component, Schema.Component> = strapi.components;
|
||||
|
||||
const countConditionalFieldsInSchema = (
|
||||
schema: Record<string, Schema.ContentType | Schema.Component>
|
||||
) => {
|
||||
return pipe(
|
||||
map('attributes'),
|
||||
flatMap(values),
|
||||
sumBy((attribute: Schema.Attribute.AnyAttribute) => {
|
||||
if (attribute.conditions && typeof attribute.conditions === 'object') {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
)(schema);
|
||||
};
|
||||
|
||||
const contentTypeCount = countConditionalFieldsInSchema(contentTypes);
|
||||
const componentCount = countConditionalFieldsInSchema(components);
|
||||
|
||||
return contentTypeCount + componentCount;
|
||||
};
|
||||
|
||||
export default getNumberOfConditionalFields;
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/data-transfer",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Data transfer capabilities for Strapi",
|
||||
"keywords": [
|
||||
"strapi",
|
||||
@ -42,9 +42,9 @@
|
||||
"watch": "run -T rollup -c -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/logger": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/logger": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"chalk": "4.1.2",
|
||||
"cli-table3": "0.6.5",
|
||||
"commander": "8.3.0",
|
||||
@ -61,7 +61,7 @@
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/database": "5.16.0",
|
||||
"@strapi/database": "5.17.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.2",
|
||||
"@types/koa": "2.13.4",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/database",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Strapi's database layer",
|
||||
"homepage": "https://strapi.io",
|
||||
"bugs": {
|
||||
@ -42,7 +42,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"ajv": "8.16.0",
|
||||
"date-fns": "2.30.0",
|
||||
"debug": "4.3.4",
|
||||
@ -54,8 +54,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"tsconfig": "5.16.0"
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/email",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Easily configure your Strapi application to send emails.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -56,10 +56,10 @@
|
||||
"watch": "run -T rollup -c -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/provider-email-sendmail": "5.16.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/provider-email-sendmail": "5.17.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"koa2-ratelimit": "^1.1.3",
|
||||
"lodash": "4.17.21",
|
||||
"react-intl": "6.6.2",
|
||||
@ -67,8 +67,8 @@
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/lodash": "^4.14.191",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/permissions",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Strapi's permission layer.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -39,14 +39,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "6.5.0",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"lodash": "4.17.21",
|
||||
"qs": "6.11.1",
|
||||
"sift": "16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-custom": "5.16.0",
|
||||
"tsconfig": "5.16.0"
|
||||
"eslint-config-custom": "5.17.0",
|
||||
"tsconfig": "5.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <=22.x.x",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@strapi/review-workflows",
|
||||
"version": "5.16.0",
|
||||
"version": "5.17.0",
|
||||
"description": "Review workflows for your content",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -57,9 +57,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@strapi/design-system": "2.0.0-rc.26",
|
||||
"@strapi/icons": "2.0.0-rc.26",
|
||||
"@strapi/utils": "5.16.0",
|
||||
"@strapi/design-system": "2.0.0-rc.27",
|
||||
"@strapi/icons": "2.0.0-rc.27",
|
||||
"@strapi/utils": "5.17.0",
|
||||
"fractional-indexing": "3.2.0",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
@ -69,9 +69,9 @@
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin": "5.16.0",
|
||||
"@strapi/content-manager": "5.16.0",
|
||||
"@strapi/types": "5.16.0",
|
||||
"@strapi/admin": "5.17.0",
|
||||
"@strapi/content-manager": "5.17.0",
|
||||
"@strapi/types": "5.17.0",
|
||||
"@testing-library/react": "15.0.7",
|
||||
"msw": "1.3.0",
|
||||
"react": "18.3.1",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user