mirror of
https://github.com/strapi/strapi.git
synced 2025-07-04 15:42:03 +00:00
Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9c7149630d | ||
![]() |
d82485a22f | ||
![]() |
b8af698e51 | ||
![]() |
e72d279690 | ||
![]() |
cedd719a48 | ||
![]() |
53c1fe2b97 | ||
![]() |
aeea877fea | ||
![]() |
943c95f8eb | ||
![]() |
44bf3c5f32 | ||
![]() |
93cd7e55a8 | ||
![]() |
74fd566f2a | ||
![]() |
e6db549a1b | ||
![]() |
f146adec87 | ||
![]() |
efb0ce3d8b | ||
![]() |
c550285b00 | ||
![]() |
5f27c76d39 | ||
![]() |
14e074cb0a | ||
![]() |
49f444740d | ||
![]() |
3e666b1ad9 | ||
![]() |
44d3605364 | ||
![]() |
07ae8e84c8 | ||
![]() |
bdbc9ea979 | ||
![]() |
e5c202da01 | ||
![]() |
ed7c7c54ff | ||
![]() |
1aeafdcd36 | ||
![]() |
e3eb76a86a | ||
![]() |
339ea3d197 | ||
![]() |
1fedcce151 | ||
![]() |
be26954af3 | ||
![]() |
1366892f87 |
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",
|
"name": "check-pr-status",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
@ -6,9 +6,16 @@ This document explains how to create and use Guided Tours in the Strapi CMS.
|
|||||||
|
|
||||||
## Creating tours
|
## Creating tours
|
||||||
|
|
||||||
To create a tour use the `createTour` factory function. The function takes the name of the tour and an array of steps.
|
To create a tour use the `createTour` factory function. The function takes the following arguments:
|
||||||
|
|
||||||
The `content` key of a step is a render prop that receives `Step` and an object with `state` and `dispatch`.
|
- `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:
|
`Step` has the following composable parts:
|
||||||
|
|
||||||
@ -24,6 +31,7 @@ const tours = {
|
|||||||
contentManager: createTour('contentManager', [
|
contentManager: createTour('contentManager', [
|
||||||
{
|
{
|
||||||
name: 'TheFeatureStepName',
|
name: 'TheFeatureStepName',
|
||||||
|
requiredActions: ['didDoSomethingImportant'],
|
||||||
content: (Step) => (
|
content: (Step) => (
|
||||||
<Step.Root side="right">
|
<Step.Root side="right">
|
||||||
<Step.Title
|
<Step.Title
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
module.exports = ({ env }) => ({
|
module.exports = ({ env }) => ({
|
||||||
future: {},
|
future: {
|
||||||
|
unstableGuidedTour: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
"strapi": "strapi"
|
"strapi": "strapi"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"@strapi/plugin-color-picker": "workspace:*",
|
"@strapi/plugin-color-picker": "workspace:*",
|
||||||
"@strapi/plugin-documentation": "workspace:*",
|
"@strapi/plugin-documentation": "workspace:*",
|
||||||
"@strapi/plugin-graphql": "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"
|
"name": "Country"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"comment": "",
|
|
||||||
"draftAndPublish": false
|
"draftAndPublish": false
|
||||||
},
|
},
|
||||||
"pluginOptions": {
|
"pluginOptions": {
|
||||||
@ -20,24 +19,38 @@
|
|||||||
"attributes": {
|
"attributes": {
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": true,
|
|
||||||
"minLength": 3,
|
|
||||||
"pluginOptions": {
|
"pluginOptions": {
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"localized": true
|
"localized": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"minLength": 3,
|
||||||
|
"required": true
|
||||||
},
|
},
|
||||||
"code": {
|
"code": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 3,
|
|
||||||
"unique": true,
|
|
||||||
"minLength": 2,
|
|
||||||
"pluginOptions": {
|
"pluginOptions": {
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"localized": true
|
"localized": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"visible": {
|
||||||
|
"==": [
|
||||||
|
{
|
||||||
|
"var": "visible"
|
||||||
|
},
|
||||||
|
true
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"minLength": 2,
|
||||||
|
"maxLength": 3,
|
||||||
|
"required": true,
|
||||||
|
"unique": true
|
||||||
|
},
|
||||||
|
"visible": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"displayName": "Homepage",
|
"displayName": "Homepage",
|
||||||
"singularName": "homepage",
|
"singularName": "homepage",
|
||||||
"pluralName": "homepages"
|
"pluralName": "homepages",
|
||||||
|
"description": ""
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"draftAndPublish": true
|
"draftAndPublish": true
|
||||||
@ -34,16 +35,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mediaType": {
|
||||||
|
"type": "enumeration",
|
||||||
|
"enum": ["multiple", "single"],
|
||||||
|
"default": "multiple",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"single": {
|
"single": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"allowedTypes": ["images", "files", "videos"],
|
"multiple": false,
|
||||||
"required": false
|
"required": false,
|
||||||
|
"conditions": {
|
||||||
|
"visible": {
|
||||||
|
"==": [
|
||||||
|
{
|
||||||
|
"var": "mediaType"
|
||||||
|
},
|
||||||
|
"single"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowedTypes": ["images", "files", "videos"]
|
||||||
},
|
},
|
||||||
"multiple": {
|
"multiple": {
|
||||||
"type": "media",
|
"type": "media",
|
||||||
"multiple": true,
|
"multiple": true,
|
||||||
"allowedTypes": ["images", "videos"],
|
"required": false,
|
||||||
"required": false
|
"conditions": {
|
||||||
|
"visible": {
|
||||||
|
"==": [
|
||||||
|
{
|
||||||
|
"var": "mediaType"
|
||||||
|
},
|
||||||
|
"multiple"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowedTypes": ["images", "videos"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,8 +27,8 @@
|
|||||||
"@strapi/strapi": "workspace:*"
|
"@strapi/strapi": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strapi/design-system": "2.0.0-rc.27",
|
"@strapi/design-system": "2.0.0-rc.28",
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"eslint": "8.50.0",
|
"eslint": "8.50.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/admin-test-utils",
|
"name": "@strapi/admin-test-utils",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Test utilities for the Strapi administration panel",
|
"description": "Test utilities for the Strapi administration panel",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -84,10 +84,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@testing-library/jest-dom": "6.4.5",
|
"@testing-library/jest-dom": "6.4.5",
|
||||||
"eslint-config-custom": "5.16.1",
|
"eslint-config-custom": "5.17.0",
|
||||||
"jest-environment-jsdom": "29.6.1",
|
"jest-environment-jsdom": "29.6.1",
|
||||||
"styled-components": "6.1.8",
|
"styled-components": "6.1.8",
|
||||||
"tsconfig": "5.16.1"
|
"tsconfig": "5.17.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@reduxjs/toolkit": "^1.9.7",
|
"@reduxjs/toolkit": "^1.9.7",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/cloud-cli",
|
"name": "@strapi/cloud-cli",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Commands to interact with the Strapi Cloud",
|
"description": "Commands to interact with the Strapi Cloud",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"strapi",
|
"strapi",
|
||||||
@ -47,7 +47,7 @@
|
|||||||
"watch": "run -T rollup -c -w"
|
"watch": "run -T rollup -c -w"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
"boxen": "5.1.2",
|
"boxen": "5.1.2",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
@ -72,8 +72,8 @@
|
|||||||
"@types/cli-progress": "3.11.5",
|
"@types/cli-progress": "3.11.5",
|
||||||
"@types/eventsource": "1.1.15",
|
"@types/eventsource": "1.1.15",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"eslint-config-custom": "5.16.1",
|
"eslint-config-custom": "5.17.0",
|
||||||
"tsconfig": "5.16.1"
|
"tsconfig": "5.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0 <=22.x.x",
|
"node": ">=18.0.0 <=22.x.x",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "create-strapi-app",
|
"name": "create-strapi-app",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Generate a new Strapi application.",
|
"description": "Generate a new Strapi application.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"create-strapi-app",
|
"create-strapi-app",
|
||||||
@ -50,7 +50,7 @@
|
|||||||
"watch": "run -T rollup -c -w"
|
"watch": "run -T rollup -c -w"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@strapi/cloud-cli": "5.16.1",
|
"@strapi/cloud-cli": "5.17.0",
|
||||||
"async-retry": "1.3.3",
|
"async-retry": "1.3.3",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"commander": "8.3.0",
|
"commander": "8.3.0",
|
||||||
@ -69,8 +69,8 @@
|
|||||||
"@types/async-retry": "^1",
|
"@types/async-retry": "^1",
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
"@types/inquirer": "8.2.5",
|
"@types/inquirer": "8.2.5",
|
||||||
"eslint-config-custom": "5.16.1",
|
"eslint-config-custom": "5.17.0",
|
||||||
"tsconfig": "5.16.1"
|
"tsconfig": "5.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0 <=22.x.x",
|
"node": ">=18.0.0 <=22.x.x",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "create-strapi",
|
"name": "create-strapi",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Generate a new Strapi application.",
|
"description": "Generate a new Strapi application.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"create-strapi",
|
"create-strapi",
|
||||||
@ -36,7 +36,7 @@
|
|||||||
"bin/"
|
"bin/"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"create-strapi-app": "5.16.1"
|
"create-strapi-app": "5.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0 <=22.x.x",
|
"node": ">=18.0.0 <=22.x.x",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { darkTheme, lightTheme } from '@strapi/design-system';
|
import { darkTheme, lightTheme } from '@strapi/design-system';
|
||||||
|
import { User } from '@strapi/icons';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import isFunction from 'lodash/isFunction';
|
import isFunction from 'lodash/isFunction';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
@ -317,6 +318,29 @@ class StrapiApp {
|
|||||||
getPlugin = (pluginId: PluginConfig['id']) => this.plugins[pluginId];
|
getPlugin = (pluginId: PluginConfig['id']) => this.plugins[pluginId];
|
||||||
|
|
||||||
async register(customRegister?: unknown) {
|
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) => {
|
Object.keys(this.appPlugins).forEach((plugin) => {
|
||||||
this.appPlugins[plugin].register(this);
|
this.appPlugins[plugin].register(this);
|
||||||
});
|
});
|
||||||
|
@ -9,7 +9,7 @@ import { styled } from 'styled-components';
|
|||||||
import { useAuth } from '../features/Auth';
|
import { useAuth } from '../features/Auth';
|
||||||
import { useTracking } from '../features/Tracking';
|
import { useTracking } from '../features/Tracking';
|
||||||
import { Menu, MenuItem } from '../hooks/useMenu';
|
import { Menu, MenuItem } from '../hooks/useMenu';
|
||||||
import { getDisplayName } from '../utils/users';
|
import { getDisplayName, getInitials } from '../utils/users';
|
||||||
|
|
||||||
import { MainNav } from './MainNav/MainNav';
|
import { MainNav } from './MainNav/MainNav';
|
||||||
import { NavBrand } from './MainNav/NavBrand';
|
import { NavBrand } from './MainNav/NavBrand';
|
||||||
@ -57,11 +57,7 @@ const LeftMenu = ({ generalSectionLinks, pluginsSectionLinks }: LeftMenuProps) =
|
|||||||
sensitivity: 'base',
|
sensitivity: 'base',
|
||||||
});
|
});
|
||||||
|
|
||||||
const initials = userDisplayName
|
const initials = getInitials(user);
|
||||||
.split(' ')
|
|
||||||
.map((name) => name.substring(0, 1))
|
|
||||||
.join('')
|
|
||||||
.substring(0, 2);
|
|
||||||
|
|
||||||
const handleClickOnLink = (destination: string) => {
|
const handleClickOnLink = (destination: string) => {
|
||||||
trackUsage('willNavigate', { from: pathname, to: destination });
|
trackUsage('willNavigate', { from: pathname, to: destination });
|
||||||
|
@ -7,9 +7,11 @@ import {
|
|||||||
BadgeProps,
|
BadgeProps,
|
||||||
AccessibleIcon,
|
AccessibleIcon,
|
||||||
} from '@strapi/design-system';
|
} 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 { styled } from 'styled-components';
|
||||||
|
|
||||||
|
import { tours as unstable_tours } from '../UnstableGuidedTour/Tours';
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------------------------------
|
||||||
* Link
|
* Link
|
||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
@ -39,8 +41,29 @@ const MainNavLinkWrapper = styled(RouterLink)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const getGuidedTourTooltip = (to: To) => {
|
||||||
|
const normalizedTo = to.toString().replace(/\//g, '');
|
||||||
|
|
||||||
|
switch (normalizedTo) {
|
||||||
|
case 'content-manager':
|
||||||
|
return unstable_tours.contentTypeBuilder.Finish;
|
||||||
|
case '':
|
||||||
|
return unstable_tours.apiTokens.Finish;
|
||||||
|
case 'settings':
|
||||||
|
return unstable_tours.contentManager.Finish;
|
||||||
|
default:
|
||||||
|
return React.Fragment;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const LinkImpl = ({ children, ...props }: LinkProps) => {
|
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}>
|
<Menu.Item onSelect={handleProfile}>
|
||||||
{formatMessage({
|
{formatMessage({
|
||||||
id: 'global.profile',
|
id: 'global.profile.settings',
|
||||||
defaultMessage: 'Profile settings',
|
defaultMessage: 'Profile settings',
|
||||||
})}
|
})}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -14,7 +14,6 @@ import { GuidedTourProvider } from './GuidedTour/Provider';
|
|||||||
import { LanguageProvider } from './LanguageProvider';
|
import { LanguageProvider } from './LanguageProvider';
|
||||||
import { Theme } from './Theme';
|
import { Theme } from './Theme';
|
||||||
import { UnstableGuidedTourContext } from './UnstableGuidedTour/Context';
|
import { UnstableGuidedTourContext } from './UnstableGuidedTour/Context';
|
||||||
import { tours } from './UnstableGuidedTour/Tours';
|
|
||||||
|
|
||||||
import type { Store } from '../core/store/configure';
|
import type { Store } from '../core/store/configure';
|
||||||
import type { StrapiApp } from '../StrapiApp';
|
import type { StrapiApp } from '../StrapiApp';
|
||||||
@ -59,7 +58,7 @@ const Providers = ({ children, strapi, store }: ProvidersProps) => {
|
|||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<TrackingProvider>
|
<TrackingProvider>
|
||||||
<GuidedTourProvider>
|
<GuidedTourProvider>
|
||||||
<UnstableGuidedTourContext tours={tours}>
|
<UnstableGuidedTourContext>
|
||||||
<ConfigurationProvider
|
<ConfigurationProvider
|
||||||
defaultAuthLogo={strapi.configurations.authLogo}
|
defaultAuthLogo={strapi.configurations.authLogo}
|
||||||
defaultMenuLogo={strapi.configurations.menuLogo}
|
defaultMenuLogo={strapi.configurations.menuLogo}
|
||||||
|
@ -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 { Box, SubNav as DSSubNav, Flex, Typography, IconButton } from '@strapi/design-system';
|
||||||
import { ChevronDown, Plus } from '@strapi/icons';
|
import { ChevronDown, Plus } from '@strapi/icons';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
|
|
||||||
|
import { tours as unstable_tours } from './UnstableGuidedTour/Tours';
|
||||||
|
|
||||||
const Main = styled(DSSubNav)`
|
const Main = styled(DSSubNav)`
|
||||||
background-color: ${({ theme }) => theme.colors.neutral0};
|
background-color: ${({ theme }) => theme.colors.neutral0};
|
||||||
border-right: 1px solid ${({ theme }) => theme.colors.neutral150};
|
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 = ({
|
const Section = ({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
@ -123,6 +143,7 @@ const Section = ({
|
|||||||
link?: { label: string; onClik: () => void };
|
link?: { label: string; onClik: () => void };
|
||||||
}) => {
|
}) => {
|
||||||
const listId = useId();
|
const listId = useId();
|
||||||
|
const GuidedTourTooltip = getGuidedTourTooltip(label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" alignItems="stretch" gap={2}>
|
<Flex direction="column" alignItems="stretch" gap={2}>
|
||||||
@ -136,6 +157,7 @@ const Section = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
{link && (
|
{link && (
|
||||||
|
<GuidedTourTooltip>
|
||||||
<IconButton
|
<IconButton
|
||||||
label={link.label}
|
label={link.label}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -145,6 +167,7 @@ const Section = ({
|
|||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</GuidedTourTooltip>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -2,9 +2,11 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
|
|
||||||
|
import { GetGuidedTourMeta } from '../../../../shared/contracts/admin';
|
||||||
|
import { usePersistentState } from '../../hooks/usePersistentState';
|
||||||
import { createContext } from '../Context';
|
import { createContext } from '../Context';
|
||||||
|
|
||||||
import type { Tours } from './Tours';
|
import { type Tours, tours as guidedTours } from './Tours';
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------------------------------
|
||||||
* GuidedTourProvider
|
* GuidedTourProvider
|
||||||
@ -12,6 +14,11 @@ import type { Tours } from './Tours';
|
|||||||
|
|
||||||
type ValidTourName = keyof Tours;
|
type ValidTourName = keyof Tours;
|
||||||
|
|
||||||
|
export type ExtendedCompletedActions = (
|
||||||
|
| GetGuidedTourMeta.Response['data']['completedActions'][number]
|
||||||
|
| 'didCopyApiToken'
|
||||||
|
)[];
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: 'next_step';
|
type: 'next_step';
|
||||||
@ -20,10 +27,20 @@ type Action =
|
|||||||
| {
|
| {
|
||||||
type: 'skip_tour';
|
type: 'skip_tour';
|
||||||
payload: ValidTourName;
|
payload: ValidTourName;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set_completed_actions';
|
||||||
|
payload: ExtendedCompletedActions;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'skip_all_tours';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
|
||||||
type State = {
|
type State = {
|
||||||
tours: Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
|
tours: Tour;
|
||||||
|
enabled: boolean;
|
||||||
|
completedActions: ExtendedCompletedActions;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
|
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
|
||||||
@ -37,40 +54,55 @@ function reducer(state: State, action: Action): State {
|
|||||||
const nextStep = draft.tours[action.payload].currentStep + 1;
|
const nextStep = draft.tours[action.payload].currentStep + 1;
|
||||||
draft.tours[action.payload].currentStep = nextStep;
|
draft.tours[action.payload].currentStep = nextStep;
|
||||||
draft.tours[action.payload].isCompleted = nextStep === draft.tours[action.payload].length;
|
draft.tours[action.payload].isCompleted = nextStep === draft.tours[action.payload].length;
|
||||||
// TODO: Update local storage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'skip_tour') {
|
if (action.type === 'skip_tour') {
|
||||||
draft.tours[action.payload].isCompleted = true;
|
draft.tours[action.payload].isCompleted = true;
|
||||||
// TODO: Update local storage
|
}
|
||||||
|
|
||||||
|
if (action.type === 'set_completed_actions') {
|
||||||
|
draft.completedActions = [...new Set([...draft.completedActions, ...action.payload])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'skip_all_tours') {
|
||||||
|
draft.enabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'STRAPI_GUIDED_TOUR';
|
||||||
|
|
||||||
const UnstableGuidedTourContext = ({
|
const UnstableGuidedTourContext = ({
|
||||||
children,
|
children,
|
||||||
tours: registeredTours,
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
// NOTE: Maybe we just import this directly instead of a prop?
|
enabled?: boolean;
|
||||||
tours: Tours;
|
|
||||||
}) => {
|
}) => {
|
||||||
// Derive the tour state from the tours object
|
const initialTourState = Object.keys(guidedTours).reduce((acc, tourName) => {
|
||||||
const tours = Object.keys(registeredTours).reduce(
|
const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length;
|
||||||
(acc, tourName) => {
|
|
||||||
const tourLength = Object.keys(registeredTours[tourName as ValidTourName]).length;
|
|
||||||
acc[tourName as ValidTourName] = {
|
acc[tourName as ValidTourName] = {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
length: tourLength,
|
length: tourLength,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
}, {} as Tour);
|
||||||
{} as Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>
|
|
||||||
);
|
const [tours, setTours] = usePersistentState<State>(STORAGE_KEY, {
|
||||||
const [state, dispatch] = React.useReducer(reducer, {
|
tours: initialTourState,
|
||||||
tours,
|
enabled,
|
||||||
|
completedActions: [],
|
||||||
});
|
});
|
||||||
|
const [state, dispatch] = React.useReducer(reducer, tours);
|
||||||
|
|
||||||
|
// Sync local storage
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (window.strapi.future.isEnabled('unstableGuidedTour')) {
|
||||||
|
setTours(state);
|
||||||
|
}
|
||||||
|
}, [state, setTours]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
|
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
|
||||||
|
@ -0,0 +1,252 @@
|
|||||||
|
import { Box, Button, Flex, Link, ProgressBar, Typography } from '@strapi/design-system';
|
||||||
|
import { CheckCircle, ChevronRight } from '@strapi/icons';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { styled, useTheme } from 'styled-components';
|
||||||
|
|
||||||
|
import { type ValidTourName, unstableUseGuidedTour } from './Context';
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* Styled
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const StyledProgressBar = styled(ProgressBar)`
|
||||||
|
width: 100%;
|
||||||
|
background-color: ${({ theme }) => theme.colors.neutral150};
|
||||||
|
> div {
|
||||||
|
background-color: ${({ theme }) => theme.colors.success500};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Container = styled(Flex)`
|
||||||
|
width: 100%;
|
||||||
|
border-radius: ${({ theme }) => theme.borderRadius};
|
||||||
|
background-color: ${({ theme }) => theme.colors.neutral0};
|
||||||
|
box-shadow: ${({ theme }) => theme.shadows.tableShadow};
|
||||||
|
align-items: stretch;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ContentSection = styled(Flex)`
|
||||||
|
flex: 1;
|
||||||
|
padding: ${({ theme }) => theme.spaces[8]};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VerticalSeparator = styled.div`
|
||||||
|
width: 1px;
|
||||||
|
background-color: ${({ theme }) => theme.colors.neutral150};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TourTaskContainer = styled(Flex)`
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: ${({ theme }) => `1px solid ${theme.colors.neutral150}`};
|
||||||
|
}
|
||||||
|
padding: ${({ theme }) => theme.spaces[4]};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TodoCircle = styled(Box)`
|
||||||
|
border: 1px solid ${({ theme }) => theme.colors.neutral300};
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 13px;
|
||||||
|
width: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* Constants
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const LINK_LABEL = {
|
||||||
|
id: 'tours.overview.tour.link',
|
||||||
|
defaultMessage: 'Start',
|
||||||
|
};
|
||||||
|
const DONE_LABEL = {
|
||||||
|
id: 'tours.overview.tour.done',
|
||||||
|
defaultMessage: 'Done',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_CONTENT = [
|
||||||
|
{
|
||||||
|
tourName: 'contentTypeBuilder',
|
||||||
|
link: {
|
||||||
|
label: LINK_LABEL,
|
||||||
|
to: '/plugins/content-type-builder',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
id: 'tours.overview.contentTypeBuilder.label',
|
||||||
|
defaultMessage: 'Create your schema',
|
||||||
|
},
|
||||||
|
done: DONE_LABEL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tourName: 'contentManager',
|
||||||
|
link: {
|
||||||
|
label: LINK_LABEL,
|
||||||
|
to: '/content-manager',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
id: 'tours.overview.contentManager.label',
|
||||||
|
defaultMessage: 'Create and publish content',
|
||||||
|
},
|
||||||
|
done: DONE_LABEL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tourName: 'apiTokens',
|
||||||
|
link: {
|
||||||
|
label: LINK_LABEL,
|
||||||
|
to: '/settings/api-tokens',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
id: 'tours.overview.apiTokens.label',
|
||||||
|
defaultMessage: 'Create and copy an API token',
|
||||||
|
},
|
||||||
|
done: DONE_LABEL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tourName: 'strapiCloud',
|
||||||
|
link: {
|
||||||
|
label: {
|
||||||
|
id: 'tours.overview.strapiCloud.link',
|
||||||
|
defaultMessage: 'Read documentation',
|
||||||
|
},
|
||||||
|
to: 'https://docs.strapi.io/cloud/intro',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
id: 'tours.overview.strapiCloud.label',
|
||||||
|
defaultMessage: 'Deploy your application to Strapi Cloud',
|
||||||
|
},
|
||||||
|
done: DONE_LABEL,
|
||||||
|
isExternal: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------------------------------
|
||||||
|
* GuidedTourOverview
|
||||||
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
const WaveIcon = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<svg width="26" height="27" viewBox="0 0 26 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M24.4138 9.30762C25.1565 10.5578 25.6441 11.9429 25.8481 13.3827C26.0522 14.8225 25.9687 16.2885 25.6026 17.6958C25.2365 19.1032 24.5949 20.4239 23.7151 21.5818C22.8352 22.7396 21.7345 23.7114 20.4766 24.4411C19.2188 25.1708 17.8287 25.6439 16.3868 25.8329C14.945 26.022 13.48 25.9232 12.0765 25.5424C10.673 25.1616 9.35903 24.5063 8.21045 23.6144C7.06188 22.7226 6.10154 21.6118 5.385 20.3464L0.268755 11.4851C0.0253867 11.0275 -0.0308559 10.4934 0.111878 9.99514C0.254612 9.49692 0.585176 9.07356 1.03392 8.81426C1.48266 8.55497 2.01453 8.47999 2.51746 8.60514C3.02039 8.73028 3.45511 9.04576 3.73001 9.48512L6.05 13.5001C6.11567 13.6139 6.20309 13.7136 6.30729 13.7936C6.41148 13.8735 6.53041 13.9322 6.65728 13.9662C6.78415 14.0002 6.91647 14.0089 7.04669 13.9918C7.17692 13.9746 7.3025 13.932 7.41625 13.8664C7.53001 13.8007 7.62972 13.7133 7.70969 13.6091C7.78966 13.5049 7.84833 13.386 7.88234 13.2591C7.91635 13.1322 7.92504 12.9999 7.90791 12.8697C7.89078 12.7395 7.84817 12.6139 7.78251 12.5001L2.87501 4.00012C2.63164 3.54255 2.57539 3.00837 2.71813 2.51014C2.86086 2.01192 3.19143 1.58856 3.64017 1.32926C4.08891 1.06997 4.62078 0.994994 5.12371 1.12014C5.62664 1.24528 6.06136 1.56077 6.33626 2.00012L11.25 10.5001C11.3137 10.6175 11.4003 10.7209 11.5046 10.8042C11.609 10.8876 11.7289 10.9492 11.8575 10.9854C11.986 11.0216 12.1205 11.0318 12.253 11.0152C12.3855 10.9986 12.5133 10.9556 12.629 10.8888C12.7446 10.8221 12.8457 10.7328 12.9263 10.6263C13.0068 10.5198 13.0653 10.3982 13.0981 10.2688C13.1309 10.1394 13.1375 10.0047 13.1174 9.87264C13.0974 9.74062 13.0511 9.61395 12.9813 9.50012L9.23125 3.00012C8.9738 2.54125 8.90753 1.99941 9.04682 1.49203C9.18612 0.984641 9.51974 0.552582 9.97539 0.289483C10.431 0.0263834 10.972 -0.0465606 11.4811 0.0864587C11.9902 0.219478 12.4263 0.547745 12.695 1.00012L17.75 9.76512C16.6322 10.8916 16.0035 12.4132 16 14.0001C15.9963 15.2989 16.4177 16.5633 17.2 17.6001C17.278 17.7074 17.3766 17.7981 17.49 17.867C17.6034 17.9358 17.7293 17.9814 17.8605 18.001C17.9917 18.0207 18.1255 18.0141 18.2541 17.9816C18.3827 17.9491 18.5035 17.8913 18.6096 17.8116C18.7156 17.7319 18.8048 17.6319 18.8718 17.5175C18.9388 17.403 18.9824 17.2763 19 17.1448C19.0176 17.0134 19.0089 16.8797 18.9743 16.7516C18.9398 16.6236 18.8801 16.5036 18.7988 16.3989C18.4824 15.9765 18.2528 15.4958 18.1231 14.9843C17.9934 14.4729 17.9661 13.9408 18.0429 13.4188C18.1197 12.8967 18.2991 12.3951 18.5706 11.9426C18.8421 11.4902 19.2005 11.096 19.625 10.7826C19.8224 10.6365 19.9592 10.4229 20.0092 10.1825C20.0592 9.94202 20.019 9.69157 19.8963 9.47887L18.4638 7.00012C18.2063 6.54125 18.14 5.99941 18.2793 5.49203C18.4186 4.98464 18.7522 4.55258 19.2079 4.28948C19.6635 4.02638 20.2045 3.95344 20.7136 4.08646C21.2227 4.21948 21.6588 4.54774 21.9275 5.00012L24.4138 9.30762ZM20.7425 2.18262C21.4432 2.36725 22.1001 2.68931 22.6752 3.13008C23.2503 3.57084 23.7321 4.12153 24.0925 4.75012L24.1338 4.82137C24.2664 5.05111 24.4848 5.21877 24.741 5.28745C24.8679 5.32146 25.0002 5.33015 25.1304 5.31302C25.2607 5.29589 25.3862 5.25328 25.5 5.18762C25.6138 5.12196 25.7135 5.03453 25.7934 4.93034C25.8734 4.82614 25.9321 4.70721 25.9661 4.58035C26.0001 4.45348 26.0088 4.32115 25.9917 4.19093C25.9745 4.0607 25.9319 3.93513 25.8663 3.82137L25.825 3.75012C25.3335 2.89321 24.6767 2.14252 23.8926 1.54167C23.1085 0.940821 22.2128 0.501801 21.2575 0.250119C21.002 0.184041 20.7307 0.221665 20.5028 0.354786C20.2749 0.487908 20.1088 0.705731 20.0409 0.960766C19.9729 1.2158 20.0085 1.48736 20.14 1.71625C20.2714 1.94513 20.488 2.11277 20.7425 2.18262ZM6.9475 25.2151C5.65171 24.1925 4.56342 22.9315 3.74126 21.5001C3.67559 21.3864 3.58817 21.2866 3.48397 21.2067C3.37978 21.1267 3.26085 21.068 3.13398 21.034C3.00711 21 2.87479 20.9913 2.74456 21.0085C2.61434 21.0256 2.48876 21.0682 2.37501 21.1339C2.26125 21.1995 2.16154 21.287 2.08157 21.3911C2.00159 21.4953 1.94293 21.6143 1.90892 21.7411C1.87491 21.868 1.86622 22.0003 1.88335 22.1306C1.90048 22.2608 1.94309 22.3864 2.00875 22.5001C2.95782 24.1511 4.21368 25.6056 5.70875 26.7851C5.91728 26.9455 6.18063 27.0173 6.44172 26.9849C6.70282 26.9525 6.94062 26.8185 7.10359 26.612C7.26655 26.4054 7.34156 26.143 7.31234 25.8815C7.28313 25.62 7.15204 25.3806 6.9475 25.2151Z"
|
||||||
|
fill={theme.colors.primary600}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnstableGuidedTourOverview = () => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const tours = unstableUseGuidedTour('Overview', (s) => s.state.tours);
|
||||||
|
const dispatch = unstableUseGuidedTour('Overview', (s) => s.dispatch);
|
||||||
|
const enabled = unstableUseGuidedTour('Overview', (s) => s.state.enabled);
|
||||||
|
const tourNames = Object.keys(tours) as ValidTourName[];
|
||||||
|
|
||||||
|
const completedTours = tourNames.filter((tourName) => tours[tourName].isCompleted);
|
||||||
|
const completionPercentage =
|
||||||
|
tourNames.length > 0 ? Math.round((completedTours.length / tourNames.length) * 100) : 0;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container tag="section" gap={0}>
|
||||||
|
{/* Greeting */}
|
||||||
|
<ContentSection direction="column" gap={2} alignItems="start">
|
||||||
|
<WaveIcon />
|
||||||
|
<Flex direction="column" alignItems="start" gap={1} paddingTop={4}>
|
||||||
|
<Typography fontSize="20px" fontWeight="bold">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'tours.overview.title',
|
||||||
|
defaultMessage: 'Discover your application!',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'tours.overview.subtitle',
|
||||||
|
defaultMessage: 'Follow the guided tour to get the most out of Strapi.',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
alignItems="start"
|
||||||
|
width="100%"
|
||||||
|
paddingTop={5}
|
||||||
|
paddingBottom={8}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Typography variant="pi">{completionPercentage}%</Typography>
|
||||||
|
<StyledProgressBar value={completionPercentage} />
|
||||||
|
</Flex>
|
||||||
|
<Button variant="tertiary" onClick={() => dispatch({ type: 'skip_all_tours' })}>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'tours.overview.close',
|
||||||
|
defaultMessage: 'Close guided tour',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</ContentSection>
|
||||||
|
<VerticalSeparator />
|
||||||
|
{/* Task List */}
|
||||||
|
<ContentSection direction="column" alignItems="start">
|
||||||
|
<Typography variant="omega" fontWeight="bold">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'tours.overview.tasks',
|
||||||
|
defaultMessage: 'Your tasks',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
<Box width="100%" borderColor="neutral150" marginTop={4} hasRadius>
|
||||||
|
{TASK_CONTENT.map((task) => {
|
||||||
|
const tour = tours[task.tourName as ValidTourName];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourTaskContainer
|
||||||
|
key={task.tourName}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
{tour.isCompleted ? (
|
||||||
|
<>
|
||||||
|
<Flex gap={2}>
|
||||||
|
<CheckCircle fill="success500" />
|
||||||
|
<Typography style={{ textDecoration: 'line-through' }} textColor="neutral500">
|
||||||
|
{formatMessage(task.title)}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
<Typography variant="omega" textColor="neutral500">
|
||||||
|
{formatMessage(task.done)}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Flex gap={2} alignItems="center">
|
||||||
|
<Flex height="16px" width="16px" justifyContent="center">
|
||||||
|
<TodoCircle />
|
||||||
|
</Flex>
|
||||||
|
<Typography>{formatMessage(task.title)}</Typography>
|
||||||
|
</Flex>
|
||||||
|
{task.isExternal ? (
|
||||||
|
<Link
|
||||||
|
isExternal
|
||||||
|
href={task.link.to}
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({ type: 'skip_tour', payload: task.tourName as ValidTourName })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatMessage(task.link.label)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link endIcon={<ChevronRight />} to={task.link.to} tag={NavLink}>
|
||||||
|
{formatMessage(task.link.label)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TourTaskContainer>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</ContentSection>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Popover, Box, Flex, Button, Typography } from '@strapi/design-system';
|
import { Popover, Box, Flex, Button, Typography, LinkButton } from '@strapi/design-system';
|
||||||
import { FormattedMessage, type MessageDescriptor } from 'react-intl';
|
import { FormattedMessage, type MessageDescriptor } from 'react-intl';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
|
|
||||||
import { unstableUseGuidedTour, ValidTourName } from './Context';
|
import { unstableUseGuidedTour, ValidTourName } from './Context';
|
||||||
@ -41,7 +42,7 @@ type Step = {
|
|||||||
Root: React.ForwardRefExoticComponent<React.ComponentProps<typeof Popover.Content>>;
|
Root: React.ForwardRefExoticComponent<React.ComponentProps<typeof Popover.Content>>;
|
||||||
Title: (props: StepProps) => React.ReactNode;
|
Title: (props: StepProps) => React.ReactNode;
|
||||||
Content: (props: StepProps) => React.ReactNode;
|
Content: (props: StepProps) => React.ReactNode;
|
||||||
Actions: (props: ActionsProps) => React.ReactNode;
|
Actions: (props: ActionsProps & { to?: string }) => React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionsContainer = styled(Flex)`
|
const ActionsContainer = styled(Flex)`
|
||||||
@ -50,7 +51,7 @@ const ActionsContainer = styled(Flex)`
|
|||||||
|
|
||||||
const createStepComponents = (tourName: ValidTourName): Step => ({
|
const createStepComponents = (tourName: ValidTourName): Step => ({
|
||||||
Root: React.forwardRef((props, ref) => (
|
Root: React.forwardRef((props, ref) => (
|
||||||
<Popover.Content ref={ref} side="top" align="center" {...props}>
|
<Popover.Content ref={ref} side="top" align="center" style={{ border: 'none' }} {...props}>
|
||||||
<Flex width="360px" direction="column" alignItems="start">
|
<Flex width="360px" direction="column" alignItems="start">
|
||||||
{props.children}
|
{props.children}
|
||||||
</Flex>
|
</Flex>
|
||||||
@ -83,11 +84,12 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
|
|
||||||
Actions: ({ showStepCount = true, showSkip = false, ...props }) => {
|
Actions: ({ showStepCount = true, showSkip = false, to, ...props }) => {
|
||||||
const dispatch = unstableUseGuidedTour('GuidedTourPopover', (s) => s.dispatch);
|
const dispatch = unstableUseGuidedTour('GuidedTourPopover', (s) => s.dispatch);
|
||||||
const state = unstableUseGuidedTour('GuidedTourPopover', (s) => s.state);
|
const state = unstableUseGuidedTour('GuidedTourPopover', (s) => s.state);
|
||||||
const currentStep = state.tours[tourName].currentStep + 1;
|
const currentStep = state.tours[tourName].currentStep + 1;
|
||||||
const tourLength = state.tours[tourName].length;
|
// 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 (
|
return (
|
||||||
<ActionsContainer width="100%" padding={3} paddingLeft={5}>
|
<ActionsContainer width="100%" padding={3} paddingLeft={5}>
|
||||||
@ -100,7 +102,7 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
|
|||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="tours.stepCount"
|
id="tours.stepCount"
|
||||||
defaultMessage="Step {currentStep} of {tourLength}"
|
defaultMessage="Step {currentStep} of {tourLength}"
|
||||||
values={{ currentStep, tourLength }}
|
values={{ currentStep, tourLength: displayedLength }}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
@ -113,9 +115,19 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
|
|||||||
<FormattedMessage id="tours.skip" defaultMessage="Skip" />
|
<FormattedMessage id="tours.skip" defaultMessage="Skip" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{to ? (
|
||||||
|
<LinkButton
|
||||||
|
tag={NavLink}
|
||||||
|
to={to}
|
||||||
|
onClick={() => dispatch({ type: 'next_step', payload: tourName })}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="tours.next" defaultMessage="Next" />
|
||||||
|
</LinkButton>
|
||||||
|
) : (
|
||||||
<Button onClick={() => dispatch({ type: 'next_step', payload: tourName })}>
|
<Button onClick={() => dispatch({ type: 'next_step', payload: tourName })}>
|
||||||
<FormattedMessage id="tours.next" defaultMessage="Next" />
|
<FormattedMessage id="tours.next" defaultMessage="Next" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Box, Popover } from '@strapi/design-system';
|
import { Box, Popover, Portal, Flex, LinkButton } from '@strapi/design-system';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
|
|
||||||
import { useAuth } from '../../features/Auth';
|
import { useGetGuidedTourMetaQuery } from '../../services/admin';
|
||||||
|
|
||||||
import { type State, type Action, unstableUseGuidedTour, ValidTourName } from './Context';
|
import {
|
||||||
|
type State,
|
||||||
|
type Action,
|
||||||
|
unstableUseGuidedTour,
|
||||||
|
ValidTourName,
|
||||||
|
ExtendedCompletedActions,
|
||||||
|
} from './Context';
|
||||||
import { Step, createStepComponents } from './Step';
|
import { Step, createStepComponents } from './Step';
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------------------------------
|
||||||
@ -13,11 +21,91 @@ import { Step, createStepComponents } from './Step';
|
|||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
const 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'),
|
||||||
|
},
|
||||||
|
]),
|
||||||
contentManager: createTour('contentManager', [
|
contentManager: createTour('contentManager', [
|
||||||
{
|
{
|
||||||
name: 'Introduction',
|
name: 'Introduction',
|
||||||
content: (Step) => (
|
content: (Step) => (
|
||||||
<Step.Root sideOffset={-36}>
|
<Step.Root side="top">
|
||||||
<Step.Title
|
<Step.Title
|
||||||
id="tours.contentManager.Introduction.title"
|
id="tours.contentManager.Introduction.title"
|
||||||
defaultMessage="Content manager"
|
defaultMessage="Content manager"
|
||||||
@ -30,7 +118,132 @@ const tours = {
|
|||||||
</Step.Root>
|
</Step.Root>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Fields',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root side={'top'} align="start" sideOffset={-36}>
|
||||||
|
<Step.Title id="tours.contentManager.Fields.title" defaultMessage="Fields" />
|
||||||
|
<Step.Content
|
||||||
|
id="tours.contentManager.Fields.content"
|
||||||
|
defaultMessage="Add content to the fields created in the Content-Type Builder."
|
||||||
|
/>
|
||||||
|
<Step.Actions />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Publish',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root side="left" align="center" sideOffset={20}>
|
||||||
|
<Step.Title id="tours.contentManager.Publish.title" defaultMessage="Publish" />
|
||||||
|
<Step.Content
|
||||||
|
id="tours.contentManager.Publish.content"
|
||||||
|
defaultMessage="Publish entries to make their content available through the Document Service API."
|
||||||
|
/>
|
||||||
|
<Step.Actions />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Finish',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root side="right" sideOffset={32}>
|
||||||
|
<Step.Title
|
||||||
|
id="tours.contentManager.FinalStep.title"
|
||||||
|
defaultMessage="It’s time to create API Tokens!"
|
||||||
|
/>
|
||||||
|
<Step.Content
|
||||||
|
id="tours.contentManager.FinalStep.content"
|
||||||
|
defaultMessage="Now that you’ve created and published content, time to create API tokens and set up permissions."
|
||||||
|
/>
|
||||||
|
<Step.Actions showStepCount={false} to="/settings/api-tokens" />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
when: (completedActions) => completedActions.includes('didCreateContent'),
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
|
apiTokens: createTour('apiTokens', [
|
||||||
|
{
|
||||||
|
name: 'Introduction',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root sideOffset={-36}>
|
||||||
|
<Step.Title id="tours.apiTokens.Introduction.title" defaultMessage="API tokens" />
|
||||||
|
<Step.Content
|
||||||
|
id="tours.apiTokens.Introduction.content"
|
||||||
|
defaultMessage="Create and manage API tokens with highly customizable permissions."
|
||||||
|
/>
|
||||||
|
<Step.Actions showSkip />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
when: (completedActions) => !completedActions.includes('didCreateApiToken'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CreateAnAPIToken',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root side="bottom" sideOffset={20} align="end">
|
||||||
|
<Step.Title
|
||||||
|
id="tours.apiTokens.CreateAnAPIToken.title"
|
||||||
|
defaultMessage="Create an API token"
|
||||||
|
/>
|
||||||
|
<Step.Content
|
||||||
|
id="tours.apiTokens.CreateAnAPIToken.content"
|
||||||
|
defaultMessage="Create a new API token. Choose a name, duration and type."
|
||||||
|
/>
|
||||||
|
<Step.Actions />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CopyAPIToken',
|
||||||
|
content: (Step) => (
|
||||||
|
<Step.Root side="bottom" align="start">
|
||||||
|
<Step.Title
|
||||||
|
id="tours.apiTokens.CopyAPIToken.title"
|
||||||
|
defaultMessage="Copy your new API token"
|
||||||
|
/>
|
||||||
|
<Step.Content
|
||||||
|
id="tours.apiTokens.CopyAPIToken.content"
|
||||||
|
defaultMessage="Make sure to do it now, you won’t be able to see it again. You’ll need to generate a new one if you lose it."
|
||||||
|
/>
|
||||||
|
<Step.Actions />
|
||||||
|
</Step.Root>
|
||||||
|
),
|
||||||
|
when: (completedActions) => completedActions.includes('didCreateApiToken'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Finish',
|
||||||
|
content: (Step) => {
|
||||||
|
const dispatch = unstableUseGuidedTour('GuidedTourPopover', (s) => s.dispatch);
|
||||||
|
return (
|
||||||
|
<Step.Root side="right" align="start" sideOffset={32}>
|
||||||
|
<Step.Title
|
||||||
|
id="tours.apiTokens.FinalStep.title"
|
||||||
|
defaultMessage="It’s time to deploy your application!"
|
||||||
|
/>
|
||||||
|
<Step.Content
|
||||||
|
id="tours.apiTokens.FinalStep.content"
|
||||||
|
defaultMessage="Your application is ready to be deployed and its content to be shared with the world!"
|
||||||
|
/>
|
||||||
|
<Step.Actions showStepCount={false}>
|
||||||
|
<Flex justifyContent="end" width={'100%'}>
|
||||||
|
<LinkButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch({ type: 'next_step', payload: 'apiTokens' });
|
||||||
|
}}
|
||||||
|
tag={NavLink}
|
||||||
|
to="/"
|
||||||
|
>
|
||||||
|
<FormattedMessage id="tours.gotIt" defaultMessage="Got it" />
|
||||||
|
</LinkButton>
|
||||||
|
</Flex>
|
||||||
|
</Step.Actions>
|
||||||
|
</Step.Root>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
when: (completedActions) => completedActions.includes('didCopyApiToken'),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
strapiCloud: createTour('strapiCloud', []),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Tours = typeof tours;
|
type Tours = typeof tours;
|
||||||
@ -50,31 +263,58 @@ type Content = (
|
|||||||
}
|
}
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
|
||||||
export const GuidedTourOverlay = styled(Box)`
|
type GuidedTourTooltipProps = {
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgba(50, 50, 77, 0.2);
|
|
||||||
z-index: 10;
|
|
||||||
pointer-events: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UnstableGuidedTourTooltip = ({
|
|
||||||
children,
|
|
||||||
content,
|
|
||||||
tourName,
|
|
||||||
step,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
content: Content;
|
content: Content;
|
||||||
tourName: ValidTourName;
|
tourName: ValidTourName;
|
||||||
step: number;
|
step: number;
|
||||||
}) => {
|
when?: (completedActions: ExtendedCompletedActions) => 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 state = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.state);
|
||||||
const dispatch = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.dispatch);
|
const dispatch = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.dispatch);
|
||||||
const Step = React.useMemo(() => createStepComponents(tourName), [tourName]);
|
|
||||||
|
|
||||||
const isCurrentStep = state.tours[tourName].currentStep === step;
|
const isCurrentStep = state.tours[tourName].currentStep === step;
|
||||||
const isPopoverOpen = isCurrentStep && !state.tours[tourName].isCompleted;
|
const isStepConditionMet = when ? when(state.completedActions) : true;
|
||||||
|
const isPopoverOpen =
|
||||||
|
guidedTourMeta?.data?.isFirstSuperAdminUser &&
|
||||||
|
!state.tours[tourName].isCompleted &&
|
||||||
|
isCurrentStep &&
|
||||||
|
isStepConditionMet;
|
||||||
|
|
||||||
// Lock the scroll
|
// Lock the scroll
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -88,9 +328,23 @@ const UnstableGuidedTourTooltip = ({
|
|||||||
};
|
};
|
||||||
}, [isPopoverOpen]);
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
|
// TODO: This isn't great but the only solution for syncing the completed actions
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch({
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: guidedTourMeta?.data?.completedActions ?? [],
|
||||||
|
});
|
||||||
|
}, [dispatch, guidedTourMeta?.data?.completedActions]);
|
||||||
|
|
||||||
|
const Step = React.useMemo(() => createStepComponents(tourName), [tourName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isPopoverOpen && <GuidedTourOverlay />}
|
{isPopoverOpen && (
|
||||||
|
<Portal>
|
||||||
|
<GuidedTourOverlay />
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
<Popover.Root open={isPopoverOpen}>
|
<Popover.Root open={isPopoverOpen}>
|
||||||
<Popover.Anchor>{children}</Popover.Anchor>
|
<Popover.Anchor>{children}</Popover.Anchor>
|
||||||
{content(Step, { state, dispatch })}
|
{content(Step, { state, dispatch })}
|
||||||
@ -106,6 +360,7 @@ const UnstableGuidedTourTooltip = ({
|
|||||||
type TourStep<P extends string> = {
|
type TourStep<P extends string> = {
|
||||||
name: P;
|
name: P;
|
||||||
content: Content;
|
content: Content;
|
||||||
|
when?: (completedActions: ExtendedCompletedActions) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
|
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
|
||||||
@ -118,15 +373,18 @@ function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: s
|
|||||||
throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`);
|
throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`);
|
||||||
}
|
}
|
||||||
|
|
||||||
acc[step.name as keyof Components] = ({ children }: { children: React.ReactNode }) => (
|
acc[step.name as keyof Components] = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
<UnstableGuidedTourTooltip
|
<UnstableGuidedTourTooltip
|
||||||
tourName={tourName as ValidTourName}
|
tourName={tourName as ValidTourName}
|
||||||
step={index}
|
step={index}
|
||||||
content={step.content}
|
content={step.content}
|
||||||
|
when={step.when}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</UnstableGuidedTourTooltip>
|
</UnstableGuidedTourTooltip>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Components);
|
}, {} as Components);
|
||||||
|
@ -1,31 +1,65 @@
|
|||||||
import { type Action, reducer } from '../Context';
|
import { type Action, type ExtendedCompletedActions, reducer } from '../Context';
|
||||||
|
|
||||||
describe('GuidedTour | reducer', () => {
|
describe('GuidedTour | reducer', () => {
|
||||||
describe('next_step', () => {
|
describe('next_step', () => {
|
||||||
it('should increment the step count for the specified tour', () => {
|
it('should increment the step count for the specified tour', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tours: {
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
contentManager: {
|
contentManager: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 2,
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
},
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const action: Action = {
|
const action: Action = {
|
||||||
type: 'next_step',
|
type: 'next_step',
|
||||||
payload: 'contentManager',
|
payload: 'contentTypeBuilder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 2,
|
length: 2,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
@ -34,37 +68,61 @@ describe('GuidedTour | reducer', () => {
|
|||||||
it('should preserve other tour states when advancing a specific tour', () => {
|
it('should preserve other tour states when advancing a specific tour', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 1,
|
length: 1,
|
||||||
},
|
},
|
||||||
contentTypeBuilder: {
|
|
||||||
currentStep: 2,
|
|
||||||
isCompleted: false,
|
|
||||||
length: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const action: Action = {
|
|
||||||
type: 'next_step',
|
|
||||||
payload: 'contentManager',
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectedState = {
|
|
||||||
tours: {
|
|
||||||
contentManager: {
|
contentManager: {
|
||||||
currentStep: 2,
|
currentStep: 2,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 1,
|
length: 1,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'next_step',
|
||||||
|
payload: 'contentTypeBuilder',
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
contentTypeBuilder: {
|
contentTypeBuilder: {
|
||||||
currentStep: 2,
|
currentStep: 2,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 1,
|
length: 1,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 2,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 1,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
@ -73,27 +131,61 @@ describe('GuidedTour | reducer', () => {
|
|||||||
it('should mark tour as completed when reaching the last step', () => {
|
it('should mark tour as completed when reaching the last step', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 1,
|
length: 1,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const action: Action = {
|
const action: Action = {
|
||||||
type: 'next_step',
|
type: 'next_step',
|
||||||
payload: 'contentManager',
|
payload: 'contentTypeBuilder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
length: 1,
|
length: 1,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
@ -104,27 +196,61 @@ describe('GuidedTour | reducer', () => {
|
|||||||
it('should mark the tour as completed', () => {
|
it('should mark the tour as completed', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 3,
|
length: 3,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const action: Action = {
|
const action: Action = {
|
||||||
type: 'skip_tour',
|
type: 'skip_tour',
|
||||||
payload: 'contentManager',
|
payload: 'contentTypeBuilder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
length: 3,
|
length: 3,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
@ -133,37 +259,459 @@ describe('GuidedTour | reducer', () => {
|
|||||||
it('should preserve other tour states when skipping a specific tour', () => {
|
it('should preserve other tour states when skipping a specific tour', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 3,
|
length: 3,
|
||||||
},
|
},
|
||||||
contentTypeBuilder: {
|
contentManager: {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 2,
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
},
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const action: Action = {
|
const action: Action = {
|
||||||
type: 'skip_tour',
|
type: 'skip_tour',
|
||||||
payload: 'contentManager',
|
payload: 'contentTypeBuilder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
tours: {
|
tours: {
|
||||||
contentManager: {
|
contentTypeBuilder: {
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
length: 3,
|
length: 3,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 1,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set_completed_actions', () => {
|
||||||
|
it('should add new actions to empty completedActions array', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: ['didCreateContentTypeSchema', 'didCreateContent'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [
|
||||||
|
'didCreateContentTypeSchema',
|
||||||
|
'didCreateContent',
|
||||||
|
] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge actions with existing ones without duplicates', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [
|
||||||
|
'didCreateContentTypeSchema',
|
||||||
|
'didCopyApiToken',
|
||||||
|
] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: ['didCreateContentTypeSchema', 'didCreateApiToken'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [
|
||||||
|
'didCreateContentTypeSchema',
|
||||||
|
'didCopyApiToken',
|
||||||
|
'didCreateApiToken',
|
||||||
|
] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty payload gracefully', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: [] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve other state properties unchanged', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 1,
|
||||||
|
isCompleted: true,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 2,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
completedActions: [] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: ['didCopyApiToken'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 1,
|
||||||
|
isCompleted: true,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 2,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
completedActions: ['didCopyApiToken'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('skip_all_tours', () => {
|
||||||
|
it('should set enabled to false while preserving tours state', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
contentTypeBuilder: {
|
contentTypeBuilder: {
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
length: 2,
|
length: 2,
|
||||||
},
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: true,
|
||||||
|
length: 2,
|
||||||
},
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 2,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'skip_all_tours',
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 1,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: true,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 2,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve completedActions array unchanged', () => {
|
||||||
|
const initialState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
completedActions: [
|
||||||
|
'didCreateContentTypeSchema',
|
||||||
|
'didCopyApiToken',
|
||||||
|
'didCreateApiToken',
|
||||||
|
] as ExtendedCompletedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action: Action = {
|
||||||
|
type: 'skip_all_tours',
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedState = {
|
||||||
|
tours: {
|
||||||
|
contentTypeBuilder: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
contentManager: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 2,
|
||||||
|
},
|
||||||
|
apiTokens: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 3,
|
||||||
|
},
|
||||||
|
strapiCloud: {
|
||||||
|
currentStep: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
completedActions: [
|
||||||
|
'didCreateContentTypeSchema',
|
||||||
|
'didCopyApiToken',
|
||||||
|
'didCreateApiToken',
|
||||||
|
] as ExtendedCompletedActions,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
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 { useAppInfo } from './AppInfo';
|
||||||
import { useAuth } from './Auth';
|
import { useAuth } from './Auth';
|
||||||
import { useStrapiApp } from './StrapiApp';
|
|
||||||
|
|
||||||
export interface TelemetryProperties {
|
export interface TelemetryProperties {
|
||||||
useTypescriptOnServer?: boolean;
|
useTypescriptOnServer?: boolean;
|
||||||
@ -40,42 +39,12 @@ export interface TrackingProviderProps {
|
|||||||
|
|
||||||
const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
||||||
const token = useAuth('App', (state) => state.token);
|
const token = useAuth('App', (state) => state.token);
|
||||||
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
|
||||||
const { data: initData } = useInitQuery();
|
const { data: initData } = useInitQuery();
|
||||||
const { uuid } = initData ?? {};
|
const { uuid } = initData ?? {};
|
||||||
|
|
||||||
const { data } = useTelemetryPropertiesQuery(undefined, {
|
const { data } = useTelemetryPropertiesQuery(undefined, {
|
||||||
skip: !initData?.uuid || !token,
|
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(
|
const value = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
uuid,
|
uuid,
|
||||||
@ -101,7 +70,6 @@ const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
|||||||
interface EventWithoutProperties {
|
interface EventWithoutProperties {
|
||||||
name:
|
name:
|
||||||
| 'changeComponentsOrder'
|
| 'changeComponentsOrder'
|
||||||
| 'didAccessAuthenticatedAdministration'
|
|
||||||
| 'didAddComponentToDynamicZone'
|
| 'didAddComponentToDynamicZone'
|
||||||
| 'didBulkDeleteEntries'
|
| 'didBulkDeleteEntries'
|
||||||
| 'didNotBulkDeleteEntries'
|
| 'didNotBulkDeleteEntries'
|
||||||
@ -197,6 +165,14 @@ interface EventWithoutProperties {
|
|||||||
properties?: never;
|
properties?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DidAccessAuthenticatedAdministrationEvent {
|
||||||
|
name: 'didAccessAuthenticatedAdministration';
|
||||||
|
properties: {
|
||||||
|
registeredWidgets: string[];
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface DidFilterMediaLibraryElementsEvent {
|
interface DidFilterMediaLibraryElementsEvent {
|
||||||
name: 'didFilterMediaLibraryElements';
|
name: 'didFilterMediaLibraryElements';
|
||||||
properties: MediaEvents['properties'] & {
|
properties: MediaEvents['properties'] & {
|
||||||
@ -384,6 +360,7 @@ interface DidUpdateCTBSchema {
|
|||||||
type EventsWithProperties =
|
type EventsWithProperties =
|
||||||
| CreateEntryEvents
|
| CreateEntryEvents
|
||||||
| PublishEntryEvents
|
| PublishEntryEvents
|
||||||
|
| DidAccessAuthenticatedAdministrationEvent
|
||||||
| DidAccessTokenListEvent
|
| DidAccessTokenListEvent
|
||||||
| DidChangeModeEvent
|
| DidChangeModeEvent
|
||||||
| DidCropFileEvent
|
| DidCropFileEvent
|
||||||
|
@ -52,13 +52,13 @@ describe('useTracking', () => {
|
|||||||
it('should call axios.post with all attributes by default when calling trackUsage()', async () => {
|
it('should call axios.post with all attributes by default when calling trackUsage()', async () => {
|
||||||
const { result } = setup();
|
const { result } = setup();
|
||||||
|
|
||||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
const res = await result.current.trackUsage('didSaveContentType');
|
||||||
|
|
||||||
expect(axios.post).toBeCalledWith(
|
expect(axios.post).toBeCalledWith(
|
||||||
'https://analytics.strapi.io/api/v2/track',
|
'https://analytics.strapi.io/api/v2/track',
|
||||||
{
|
{
|
||||||
userId: 'someTestUserId',
|
userId: 'someTestUserId',
|
||||||
event: 'didAccessAuthenticatedAdministration',
|
event: 'didSaveContentType',
|
||||||
eventProperties: {},
|
eventProperties: {},
|
||||||
groupProperties: {
|
groupProperties: {
|
||||||
useTypescriptOnServer: true,
|
useTypescriptOnServer: true,
|
||||||
@ -70,7 +70,7 @@ describe('useTracking', () => {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Strapi-Event': 'didAccessAuthenticatedAdministration',
|
'X-Strapi-Event': 'didSaveContentType',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -85,7 +85,7 @@ describe('useTracking', () => {
|
|||||||
|
|
||||||
const { result } = setup();
|
const { result } = setup();
|
||||||
|
|
||||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
await result.current.trackUsage('didSaveContentType');
|
||||||
|
|
||||||
expect(axios.post).not.toBeCalled();
|
expect(axios.post).not.toBeCalled();
|
||||||
|
|
||||||
@ -97,7 +97,7 @@ describe('useTracking', () => {
|
|||||||
|
|
||||||
const { result } = setup();
|
const { result } = setup();
|
||||||
|
|
||||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
const res = await result.current.trackUsage('didSaveContentType');
|
||||||
|
|
||||||
expect(axios.post).toHaveBeenCalled();
|
expect(axios.post).toHaveBeenCalled();
|
||||||
expect(res).toEqual(null);
|
expect(res).toEqual(null);
|
||||||
@ -114,7 +114,7 @@ describe('useTracking', () => {
|
|||||||
|
|
||||||
const { result } = setup();
|
const { result } = setup();
|
||||||
|
|
||||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
await result.current.trackUsage('didSaveContentType');
|
||||||
|
|
||||||
expect(axios.post).not.toBeCalled();
|
expect(axios.post).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
@ -79,6 +79,7 @@ export type { Widget as WidgetType } from './core/apis/Widgets';
|
|||||||
export { translatedErrors } from './utils/translatedErrors';
|
export { translatedErrors } from './utils/translatedErrors';
|
||||||
export * from './utils/getFetchClient';
|
export * from './utils/getFetchClient';
|
||||||
export * from './utils/baseQuery';
|
export * from './utils/baseQuery';
|
||||||
|
export * from './utils/rulesEngine';
|
||||||
export * from './services/api';
|
export * from './services/api';
|
||||||
export type { CMAdminConfiguration } from './types/adminConfiguration';
|
export type { CMAdminConfiguration } from './types/adminConfiguration';
|
||||||
|
|
||||||
|
@ -20,9 +20,9 @@ import { UpsellBanner } from '../components/UpsellBanner';
|
|||||||
import { AppInfoProvider } from '../features/AppInfo';
|
import { AppInfoProvider } from '../features/AppInfo';
|
||||||
import { useAuth } from '../features/Auth';
|
import { useAuth } from '../features/Auth';
|
||||||
import { useConfiguration } from '../features/Configuration';
|
import { useConfiguration } from '../features/Configuration';
|
||||||
|
import { useStrapiApp } from '../features/StrapiApp';
|
||||||
import { useTracking } from '../features/Tracking';
|
import { useTracking } from '../features/Tracking';
|
||||||
import { useMenu } from '../hooks/useMenu';
|
import { useMenu } from '../hooks/useMenu';
|
||||||
import { useOnce } from '../hooks/useOnce';
|
|
||||||
import { useInformationQuery } from '../services/admin';
|
import { useInformationQuery } from '../services/admin';
|
||||||
import { hashAdminUserEmail } from '../utils/users';
|
import { hashAdminUserEmail } from '../utils/users';
|
||||||
|
|
||||||
@ -93,14 +93,16 @@ const AdminLayout = () => {
|
|||||||
pluginsSectionLinks,
|
pluginsSectionLinks,
|
||||||
} = useMenu(checkLatestStrapiVersion(strapiVersion, tagName));
|
} = useMenu(checkLatestStrapiVersion(strapiVersion, tagName));
|
||||||
|
|
||||||
/**
|
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
||||||
* Make sure the event is only send once after accessing the admin panel
|
const projectId = appInfo?.projectId;
|
||||||
* and not at runtime for example when regenerating the permissions with the ctb
|
React.useEffect(() => {
|
||||||
* or with i18n
|
if (projectId) {
|
||||||
*/
|
trackUsage('didAccessAuthenticatedAdministration', {
|
||||||
useOnce(() => {
|
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
|
||||||
trackUsage('didAccessAuthenticatedAdministration');
|
projectId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}, [projectId, getAllWidgets, trackUsage]);
|
||||||
|
|
||||||
// We don't need to wait for the release query to be fetched before rendering the plugins
|
// 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
|
// however, we need the appInfos and the permissions
|
||||||
|
@ -7,6 +7,7 @@ import { Link as ReactRouterLink } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Layouts } from '../../components/Layouts/Layout';
|
import { Layouts } from '../../components/Layouts/Layout';
|
||||||
import { Page } from '../../components/PageHelpers';
|
import { Page } from '../../components/PageHelpers';
|
||||||
|
import { UnstableGuidedTourOverview } from '../../components/UnstableGuidedTour/Overview';
|
||||||
import { Widget } from '../../components/WidgetHelpers';
|
import { Widget } from '../../components/WidgetHelpers';
|
||||||
import { useEnterprise } from '../../ee';
|
import { useEnterprise } from '../../ee';
|
||||||
import { useAuth } from '../../features/Auth';
|
import { useAuth } from '../../features/Auth';
|
||||||
@ -153,7 +154,11 @@ const HomePageCE = () => {
|
|||||||
<FreeTrialEndedModal />
|
<FreeTrialEndedModal />
|
||||||
<Layouts.Content>
|
<Layouts.Content>
|
||||||
<Flex direction="column" alignItems="stretch" gap={8} paddingBottom={10}>
|
<Flex direction="column" alignItems="stretch" gap={8} paddingBottom={10}>
|
||||||
|
{window.strapi.future.isEnabled('unstableGuidedTour') ? (
|
||||||
|
<UnstableGuidedTourOverview />
|
||||||
|
) : (
|
||||||
<GuidedTour />
|
<GuidedTour />
|
||||||
|
)}
|
||||||
<Grid.Root gap={5}>
|
<Grid.Root gap={5}>
|
||||||
{getAllWidgets().map((widget) => {
|
{getAllWidgets().map((widget) => {
|
||||||
return (
|
return (
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { IconButton } from '@strapi/design-system';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { IconButton, Flex, Box, Typography, Button } from '@strapi/design-system';
|
||||||
import { Duplicate, Key } from '@strapi/icons';
|
import { Duplicate, Key } from '@strapi/icons';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { styled } from 'styled-components';
|
||||||
|
|
||||||
import { ContentBox } from '../../../../components/ContentBox';
|
import { ContentBox } from '../../../../components/ContentBox';
|
||||||
|
import { unstableUseGuidedTour } from '../../../../components/UnstableGuidedTour/Context';
|
||||||
|
import { tours as unstable_tours } from '../../../../components/UnstableGuidedTour/Tours';
|
||||||
import { useNotification } from '../../../../features/Notifications';
|
import { useNotification } from '../../../../features/Notifications';
|
||||||
import { useTracking } from '../../../../features/Tracking';
|
import { useTracking } from '../../../../features/Tracking';
|
||||||
import { useClipboard } from '../../../../hooks/useClipboard';
|
import { useClipboard } from '../../../../hooks/useClipboard';
|
||||||
@ -12,6 +17,82 @@ interface TokenBoxProps {
|
|||||||
tokenType: 'transfer-token' | 'api-token';
|
tokenType: 'transfer-token' | 'api-token';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TypographyWordBreak = styled(Typography)`
|
||||||
|
word-break: break-all;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UnstableApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const { toggleNotification } = useNotification();
|
||||||
|
const { trackUsage } = useTracking();
|
||||||
|
const dispatch = unstableUseGuidedTour('TokenBox', (s) => s.dispatch);
|
||||||
|
|
||||||
|
const { copy } = useClipboard();
|
||||||
|
|
||||||
|
const handleClick = (token: TokenBoxProps['token']) => async () => {
|
||||||
|
if (token) {
|
||||||
|
const didCopy = await copy(token);
|
||||||
|
|
||||||
|
if (didCopy) {
|
||||||
|
trackUsage('didCopyTokenKey', {
|
||||||
|
tokenType,
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: 'set_completed_actions',
|
||||||
|
payload: ['didCopyApiToken'],
|
||||||
|
});
|
||||||
|
toggleNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: formatMessage({ id: 'Settings.tokens.notification.copied' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
shadow="tableShadow"
|
||||||
|
direction="column"
|
||||||
|
alignItems="start"
|
||||||
|
hasRadius
|
||||||
|
padding={6}
|
||||||
|
background="neutral0"
|
||||||
|
>
|
||||||
|
<Flex direction="column" alignItems="start" gap={1} paddingBottom={4}>
|
||||||
|
<Typography fontWeight="bold">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Settings.tokens.copy.title',
|
||||||
|
defaultMessage: 'Token',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Settings.tokens.copy.lastWarning',
|
||||||
|
defaultMessage: 'Make sure to copy this token, you won’t be able to see it again!',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
<Box background="neutral100" hasRadius padding={2} borderColor="neutral150">
|
||||||
|
<TypographyWordBreak fontWeight="semiBold" variant="pi">
|
||||||
|
{token}
|
||||||
|
</TypographyWordBreak>
|
||||||
|
</Box>
|
||||||
|
<unstable_tours.apiTokens.CopyAPIToken>
|
||||||
|
<Button
|
||||||
|
startIcon={<Duplicate />}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleClick(token)}
|
||||||
|
marginTop={6}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'Settings.tokens.copy.copy', defaultMessage: 'Copy' })}
|
||||||
|
</Button>
|
||||||
|
</unstable_tours.apiTokens.CopyAPIToken>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
|
export const TokenBox = ({ token, tokenType }: TokenBoxProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { toggleNotification } = useNotification();
|
const { toggleNotification } = useNotification();
|
||||||
|
@ -22,7 +22,7 @@ import { useGetPermissionsQuery, useGetRoutesQuery } from '../../../../../servic
|
|||||||
import { isBaseQueryError } from '../../../../../utils/baseQuery';
|
import { isBaseQueryError } from '../../../../../utils/baseQuery';
|
||||||
import { API_TOKEN_TYPE } from '../../../components/Tokens/constants';
|
import { API_TOKEN_TYPE } from '../../../components/Tokens/constants';
|
||||||
import { FormHead } from '../../../components/Tokens/FormHead';
|
import { FormHead } from '../../../components/Tokens/FormHead';
|
||||||
import { TokenBox } from '../../../components/Tokens/TokenBox';
|
import { TokenBox, UnstableApiTokenBox } from '../../../components/Tokens/TokenBox';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApiTokenPermissionsContextValue,
|
ApiTokenPermissionsContextValue,
|
||||||
@ -391,8 +391,17 @@ export const EditView = () => {
|
|||||||
<Layouts.Content>
|
<Layouts.Content>
|
||||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||||
{apiToken?.accessKey && showToken && (
|
{apiToken?.accessKey && showToken && (
|
||||||
|
<>
|
||||||
|
{window.strapi.future.isEnabled('unstableGuidedTour') ? (
|
||||||
|
<UnstableApiTokenBox
|
||||||
|
token={apiToken.accessKey}
|
||||||
|
tokenType={API_TOKEN_TYPE}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
|
<TokenBox token={apiToken.accessKey} tokenType={API_TOKEN_TYPE} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormApiTokenContainer
|
<FormApiTokenContainer
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
@ -133,7 +133,7 @@ export const CollapsableContentType = ({
|
|||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{action.action}
|
<span style={{ overflowWrap: 'anywhere' }}>{action.action}</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -10,6 +10,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import { useGuidedTour } from '../../../../components/GuidedTour/Provider';
|
import { useGuidedTour } from '../../../../components/GuidedTour/Provider';
|
||||||
import { Layouts } from '../../../../components/Layouts/Layout';
|
import { Layouts } from '../../../../components/Layouts/Layout';
|
||||||
import { Page } from '../../../../components/PageHelpers';
|
import { Page } from '../../../../components/PageHelpers';
|
||||||
|
import { tours as unstable_tours } from '../../../../components/UnstableGuidedTour/Tours';
|
||||||
import { useTypedSelector } from '../../../../core/store/hooks';
|
import { useTypedSelector } from '../../../../core/store/hooks';
|
||||||
import { useNotification } from '../../../../features/Notifications';
|
import { useNotification } from '../../../../features/Notifications';
|
||||||
import { useTracking } from '../../../../features/Tracking';
|
import { useTracking } from '../../../../features/Tracking';
|
||||||
@ -148,6 +149,7 @@ export const ListView = () => {
|
|||||||
})}
|
})}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
canCreate && (
|
canCreate && (
|
||||||
|
<unstable_tours.apiTokens.CreateAnAPIToken>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
tag={Link}
|
tag={Link}
|
||||||
data-testid="create-api-token-button"
|
data-testid="create-api-token-button"
|
||||||
@ -165,6 +167,7 @@ export const ListView = () => {
|
|||||||
defaultMessage: 'Create new API Token',
|
defaultMessage: 'Create new API Token',
|
||||||
})}
|
})}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
</unstable_tours.apiTokens.CreateAnAPIToken>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -172,6 +175,7 @@ export const ListView = () => {
|
|||||||
<Page.NoPermissions />
|
<Page.NoPermissions />
|
||||||
) : (
|
) : (
|
||||||
<Page.Main aria-busy={isLoading}>
|
<Page.Main aria-busy={isLoading}>
|
||||||
|
<unstable_tours.apiTokens.Introduction>
|
||||||
<Layouts.Content>
|
<Layouts.Content>
|
||||||
{apiTokens.length > 0 && (
|
{apiTokens.length > 0 && (
|
||||||
<Table
|
<Table
|
||||||
@ -215,6 +219,7 @@ export const ListView = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Layouts.Content>
|
</Layouts.Content>
|
||||||
|
</unstable_tours.apiTokens.Introduction>
|
||||||
</Page.Main>
|
</Page.Main>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -288,14 +288,10 @@ const TABLE_HEADERS: Array<
|
|||||||
cellFormatter({ isActive }) {
|
cellFormatter({ isActive }) {
|
||||||
return (
|
return (
|
||||||
<Flex>
|
<Flex>
|
||||||
<Status
|
<Status size="S" variant={isActive ? 'success' : 'danger'}>
|
||||||
size="S"
|
<Typography tag="span" variant="omega" fontWeight="bold">
|
||||||
borderWidth={0}
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
background="transparent"
|
</Typography>
|
||||||
color="neutral800"
|
|
||||||
variant={isActive ? 'success' : 'danger'}
|
|
||||||
>
|
|
||||||
<Typography>{isActive ? 'Active' : 'Inactive'}</Typography>
|
|
||||||
</Status>
|
</Status>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
type UpdateProjectSettings,
|
type UpdateProjectSettings,
|
||||||
type Plugins,
|
type Plugins,
|
||||||
type GetLicenseLimitInformation,
|
type GetLicenseLimitInformation,
|
||||||
|
GetGuidedTourMeta,
|
||||||
} from '../../../shared/contracts/admin';
|
} from '../../../shared/contracts/admin';
|
||||||
import { prefixFileUrlWithBackendUrl } from '../utils/urls';
|
import { prefixFileUrlWithBackendUrl } from '../utils/urls';
|
||||||
|
|
||||||
@ -21,7 +22,7 @@ interface ConfigurationLogo {
|
|||||||
|
|
||||||
const admin = adminApi
|
const admin = adminApi
|
||||||
.enhanceEndpoints({
|
.enhanceEndpoints({
|
||||||
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft'],
|
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft', 'GuidedTourMeta'],
|
||||||
})
|
})
|
||||||
.injectEndpoints({
|
.injectEndpoints({
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
@ -33,6 +34,7 @@ const admin = adminApi
|
|||||||
transformResponse(res: Init.Response) {
|
transformResponse(res: Init.Response) {
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
providesTags: ['ProjectSettings'],
|
||||||
}),
|
}),
|
||||||
information: builder.query<Information.Response['data'], void>({
|
information: builder.query<Information.Response['data'], void>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
@ -114,6 +116,13 @@ const admin = adminApi
|
|||||||
}),
|
}),
|
||||||
providesTags: ['LicenseTrialTimeLeft'],
|
providesTags: ['LicenseTrialTimeLeft'],
|
||||||
}),
|
}),
|
||||||
|
getGuidedTourMeta: builder.query<GetGuidedTourMeta.Response, void>({
|
||||||
|
query: () => ({
|
||||||
|
url: '/admin/guided-tour-meta',
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
providesTags: ['GuidedTourMeta'],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@ -127,6 +136,7 @@ const {
|
|||||||
useGetPluginsQuery,
|
useGetPluginsQuery,
|
||||||
useGetLicenseLimitsQuery,
|
useGetLicenseLimitsQuery,
|
||||||
useGetLicenseTrialTimeLeftQuery,
|
useGetLicenseTrialTimeLeftQuery,
|
||||||
|
useGetGuidedTourMetaQuery,
|
||||||
} = admin;
|
} = admin;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -138,6 +148,7 @@ export {
|
|||||||
useGetPluginsQuery,
|
useGetPluginsQuery,
|
||||||
useGetLicenseLimitsQuery,
|
useGetLicenseLimitsQuery,
|
||||||
useGetLicenseTrialTimeLeftQuery,
|
useGetLicenseTrialTimeLeftQuery,
|
||||||
|
useGetGuidedTourMetaQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { ConfigurationLogo };
|
export type { ConfigurationLogo };
|
||||||
|
@ -4,7 +4,7 @@ import { adminApi } from './api';
|
|||||||
|
|
||||||
const apiTokensService = adminApi
|
const apiTokensService = adminApi
|
||||||
.enhanceEndpoints({
|
.enhanceEndpoints({
|
||||||
addTagTypes: ['ApiToken'],
|
addTagTypes: ['ApiToken', 'GuidedTourMeta'],
|
||||||
})
|
})
|
||||||
.injectEndpoints({
|
.injectEndpoints({
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
@ -31,7 +31,7 @@ const apiTokensService = adminApi
|
|||||||
data: body,
|
data: body,
|
||||||
}),
|
}),
|
||||||
transformResponse: (response: ApiToken.Create.Response) => response.data,
|
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<
|
deleteAPIToken: builder.mutation<
|
||||||
ApiToken.Revoke.Response['data'],
|
ApiToken.Revoke.Response['data'],
|
||||||
|
@ -265,6 +265,7 @@
|
|||||||
"Settings.tokens.RegenerateDialog.title": "Regenerate token",
|
"Settings.tokens.RegenerateDialog.title": "Regenerate token",
|
||||||
"Settings.tokens.copy.editMessage": "For security reasons, you can only see your token once.",
|
"Settings.tokens.copy.editMessage": "For security reasons, you can only see your token once.",
|
||||||
"Settings.tokens.copy.editTitle": "This token isn’t accessible anymore.",
|
"Settings.tokens.copy.editTitle": "This token isn’t accessible anymore.",
|
||||||
|
"Settings.tokens.copy.title": "Token",
|
||||||
"Settings.tokens.copy.lastWarning": "Make sure to copy this token, you won’t be able to see it again!",
|
"Settings.tokens.copy.lastWarning": "Make sure to copy this token, you won’t be able to see it again!",
|
||||||
"Settings.tokens.duration.30-days": "30 days",
|
"Settings.tokens.duration.30-days": "30 days",
|
||||||
"Settings.tokens.duration.7-days": "7 days",
|
"Settings.tokens.duration.7-days": "7 days",
|
||||||
@ -758,7 +759,8 @@
|
|||||||
"global.plugins.upload.description": "Media file management.",
|
"global.plugins.upload.description": "Media file management.",
|
||||||
"global.plugins.users-permissions": "Roles & Permissions",
|
"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.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.prompt.unsaved": "Are you sure you want to leave this page? All your modifications will be lost",
|
||||||
"global.reset-password": "Reset password",
|
"global.reset-password": "Reset password",
|
||||||
"global.roles": "Roles",
|
"global.roles": "Roles",
|
||||||
@ -802,7 +804,32 @@
|
|||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"tours.contentManager.Introduction.title": "Content manager",
|
"tours.contentManager.Introduction.title": "Content manager",
|
||||||
"tours.contentManager.Introduction.content": "Create and manage content from your collection types and single types.",
|
"tours.contentManager.Introduction.content": "Create and manage content from your collection types and single types.",
|
||||||
|
"tours.apiTokens.Introduction.title": "API tokens",
|
||||||
|
"tours.apiTokens.Introduction.content": "Create and manage API tokens with highly customizable permissions.",
|
||||||
|
"tours.apiTokens.CreateAnAPIToken.title": "Create an API token",
|
||||||
|
"tours.apiTokens.CreateAnAPIToken.content": "Create a new API token. Choose a name, duration and type.",
|
||||||
|
"tours.apiTokens.CopyAPIToken.title": "Copy your new API token",
|
||||||
|
"tours.apiTokens.CopyAPIToken.content": "Make sure to do it now, you won’t be able to see it again. You’ll need to generate a new one if you lose it.",
|
||||||
|
"tours.contentManager.Fields.title": "Fields",
|
||||||
|
"tours.contentManager.Fields.content": "Add content to the fields created in the Content-Type Builder.",
|
||||||
|
"tours.contentManager.Publish.title": "Publish",
|
||||||
|
"tours.contentManager.Publish.content": "Publish entries to make their content available through the Document Service API.",
|
||||||
|
"tours.contentManager.FinalStep.title": "It’s time to create API Tokens!",
|
||||||
|
"tours.contentManager.FinalStep.content": "Now that you’ve created and published content, time to create API tokens and set up permissions.",
|
||||||
"tours.stepCount": "Step {currentStep} of {tourLength}",
|
"tours.stepCount": "Step {currentStep} of {tourLength}",
|
||||||
"tours.skip": "Skip",
|
"tours.skip": "Skip",
|
||||||
"tours.next": "Next"
|
"tours.next": "Next",
|
||||||
|
"tours.gotIt": "Got it",
|
||||||
|
"tours.overview.title": "Discover your application!",
|
||||||
|
"tours.overview.subtitle": "Follow the guided tour to get the most out of Strapi.",
|
||||||
|
"tours.overview.close": "Close guided tour",
|
||||||
|
"tours.overview.tasks": "Your tasks",
|
||||||
|
"tours.overview.contentTypeBuilder.label": "Create your schema",
|
||||||
|
"tours.overview.contentManager.label": "Create and publish content",
|
||||||
|
"tours.overview.apiTokens.label": "Create and copy an API token",
|
||||||
|
"tours.overview.strapiCloud.label": "Deploy your application to Strapi Cloud",
|
||||||
|
"tours.overview.strapiCloud.link": "Read documentation",
|
||||||
|
"tours.overview.tour.link": "Start",
|
||||||
|
"tours.overview.tour.done": "Done",
|
||||||
|
"widget.profile.title": "Profile"
|
||||||
}
|
}
|
||||||
|
@ -555,6 +555,7 @@
|
|||||||
"global.plugins.users-permissions": "Roles & Permisos",
|
"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.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": "Perfil",
|
||||||
|
"global.profile.settings": "Ajustes del perfil",
|
||||||
"global.reset-password": "Resetear contraseña",
|
"global.reset-password": "Resetear contraseña",
|
||||||
"global.roles": "Roles",
|
"global.roles": "Roles",
|
||||||
"global.save": "Guardar",
|
"global.save": "Guardar",
|
||||||
|
@ -504,6 +504,7 @@
|
|||||||
"global.password": "Mot de passe",
|
"global.password": "Mot de passe",
|
||||||
"global.plugins": "Plugins",
|
"global.plugins": "Plugins",
|
||||||
"global.profile": "Profil",
|
"global.profile": "Profil",
|
||||||
|
"global.profile.settings": "Paramètres du profil",
|
||||||
"global.reset-password": "Réinitialiser le mot de passe",
|
"global.reset-password": "Réinitialiser le mot de passe",
|
||||||
"global.roles": "Rôles",
|
"global.roles": "Rôles",
|
||||||
"global.save": "Enregistrer",
|
"global.save": "Enregistrer",
|
||||||
@ -561,5 +562,6 @@
|
|||||||
"components.Blocks.blocks.quote": "Citation",
|
"components.Blocks.blocks.quote": "Citation",
|
||||||
"components.Blocks.blocks.image": "Image",
|
"components.Blocks.blocks.image": "Image",
|
||||||
"components.Blocks.blocks.unorderedList": "Liste à puces",
|
"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', () => {
|
describe('RulesEngine with is & isNot operator', () => {
|
||||||
const engine = createRulesEngine();
|
const engine = createRulesEngine();
|
@ -1,11 +1,55 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { TextEncoder } from 'util';
|
import { TextEncoder } from 'util';
|
||||||
|
|
||||||
import { hashAdminUserEmail } from '../users';
|
import { hashAdminUserEmail, getInitials, getDisplayName } from '../users';
|
||||||
|
|
||||||
const testHashValue = '8544bf5b5389959462912699664f03ed664a4b6d24f03b13bdbc362efc147873';
|
const testHashValue = '8544bf5b5389959462912699664f03ed664a4b6d24f03b13bdbc362efc147873';
|
||||||
|
|
||||||
describe('users', () => {
|
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', () => {
|
describe('hashAdminUserEmail', () => {
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
Object.defineProperty(window.self, 'crypto', {
|
Object.defineProperty(window.self, 'crypto', {
|
||||||
|
@ -20,6 +20,24 @@ const getDisplayName = ({ firstname, lastname, username, email }: Partial<User>
|
|||||||
return email ?? '';
|
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
|
* hashAdminUserEmail
|
||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
@ -46,4 +64,4 @@ const digestMessage = async (message: string) => {
|
|||||||
return bufferToHex(hashBuffer);
|
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
|
* WEBHOOKS
|
||||||
*/
|
*/
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable check-file/filename-naming-convention */
|
/* eslint-disable check-file/filename-naming-convention */
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { ConfigureStoreOptions, configureStore } from '@reduxjs/toolkit';
|
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 { GuidedTourProvider } from '../src/components/GuidedTour/Provider';
|
||||||
import { LanguageProvider } from '../src/components/LanguageProvider';
|
import { LanguageProvider } from '../src/components/LanguageProvider';
|
||||||
import { Theme } from '../src/components/Theme';
|
import { Theme } from '../src/components/Theme';
|
||||||
|
import { UnstableGuidedTourContext } from '../src/components/UnstableGuidedTour/Context';
|
||||||
import { RBAC } from '../src/core/apis/rbac';
|
import { RBAC } from '../src/core/apis/rbac';
|
||||||
import { AppInfoProvider } from '../src/features/AppInfo';
|
import { AppInfoProvider } from '../src/features/AppInfo';
|
||||||
import { AuthProvider, type Permission } from '../src/features/Auth';
|
import { AuthProvider, type Permission } from '../src/features/Auth';
|
||||||
@ -158,6 +160,7 @@ const Providers = ({ children, initialEntries, storeConfig, permissions = [] }:
|
|||||||
>
|
>
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<GuidedTourProvider>
|
<GuidedTourProvider>
|
||||||
|
<UnstableGuidedTourContext enabled={false}>
|
||||||
<ConfigurationContextProvider
|
<ConfigurationContextProvider
|
||||||
showReleaseNotification={false}
|
showReleaseNotification={false}
|
||||||
logos={{
|
logos={{
|
||||||
@ -180,6 +183,7 @@ const Providers = ({ children, initialEntries, storeConfig, permissions = [] }:
|
|||||||
{children}
|
{children}
|
||||||
</AppInfoProvider>
|
</AppInfoProvider>
|
||||||
</ConfigurationContextProvider>
|
</ConfigurationContextProvider>
|
||||||
|
</UnstableGuidedTourContext>
|
||||||
</GuidedTourProvider>
|
</GuidedTourProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</Theme>
|
</Theme>
|
||||||
|
@ -7,20 +7,7 @@ export default {
|
|||||||
path: '/license-limit-information',
|
path: '/license-limit-information',
|
||||||
handler: 'admin.licenseLimitInformation',
|
handler: 'admin.licenseLimitInformation',
|
||||||
config: {
|
config: {
|
||||||
policies: [
|
policies: ['admin::isAuthenticatedAdmin'],
|
||||||
'admin::isAuthenticatedAdmin',
|
|
||||||
{
|
|
||||||
name: 'admin::hasPermissions',
|
|
||||||
config: {
|
|
||||||
actions: [
|
|
||||||
'admin::users.create',
|
|
||||||
'admin::users.read',
|
|
||||||
'admin::users.update',
|
|
||||||
'admin::users.delete',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/admin",
|
"name": "@strapi/admin",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Strapi Admin",
|
"description": "Strapi Admin",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -84,12 +84,12 @@
|
|||||||
"@radix-ui/react-context": "1.0.1",
|
"@radix-ui/react-context": "1.0.1",
|
||||||
"@radix-ui/react-toolbar": "1.0.4",
|
"@radix-ui/react-toolbar": "1.0.4",
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@strapi/design-system": "2.0.0-rc.27",
|
"@strapi/design-system": "2.0.0-rc.28",
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"@strapi/permissions": "5.16.1",
|
"@strapi/permissions": "5.17.0",
|
||||||
"@strapi/types": "5.16.1",
|
"@strapi/types": "5.17.0",
|
||||||
"@strapi/typescript-utils": "5.16.1",
|
"@strapi/typescript-utils": "5.17.0",
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"@testing-library/dom": "10.1.0",
|
"@testing-library/dom": "10.1.0",
|
||||||
"@testing-library/react": "15.0.7",
|
"@testing-library/react": "15.0.7",
|
||||||
"@testing-library/user-event": "14.5.2",
|
"@testing-library/user-event": "14.5.2",
|
||||||
@ -110,6 +110,7 @@
|
|||||||
"inquirer": "8.2.5",
|
"inquirer": "8.2.5",
|
||||||
"invariant": "^2.2.4",
|
"invariant": "^2.2.4",
|
||||||
"is-localhost-ip": "2.0.0",
|
"is-localhost-ip": "2.0.0",
|
||||||
|
"json-logic-js": "2.0.5",
|
||||||
"jsonwebtoken": "9.0.0",
|
"jsonwebtoken": "9.0.0",
|
||||||
"koa": "2.16.1",
|
"koa": "2.16.1",
|
||||||
"koa-compose": "4.1.0",
|
"koa-compose": "4.1.0",
|
||||||
@ -143,11 +144,12 @@
|
|||||||
"zod": "3.24.2"
|
"zod": "3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@strapi/admin-test-utils": "5.16.1",
|
"@strapi/admin-test-utils": "5.17.0",
|
||||||
"@strapi/data-transfer": "5.16.1",
|
"@strapi/data-transfer": "5.17.0",
|
||||||
"@types/codemirror5": "npm:@types/codemirror@^5.60.15",
|
"@types/codemirror5": "npm:@types/codemirror@^5.60.15",
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
"@types/invariant": "2.2.36",
|
"@types/invariant": "2.2.36",
|
||||||
|
"@types/json-logic-js": "2.0.8",
|
||||||
"@types/jsonwebtoken": "9.0.3",
|
"@types/jsonwebtoken": "9.0.3",
|
||||||
"@types/koa-passport": "6.0.1",
|
"@types/koa-passport": "6.0.1",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
@ -4,6 +4,7 @@ import { async } from '@strapi/utils';
|
|||||||
import { getService } from './utils';
|
import { getService } from './utils';
|
||||||
import adminActions from './config/admin-actions';
|
import adminActions from './config/admin-actions';
|
||||||
import adminConditions from './config/admin-conditions';
|
import adminConditions from './config/admin-conditions';
|
||||||
|
import constants from './services/constants';
|
||||||
|
|
||||||
const defaultAdminAuthSettings = {
|
const defaultAdminAuthSettings = {
|
||||||
providers: {
|
providers: {
|
||||||
@ -88,21 +89,9 @@ const createDefaultAPITokensIfNeeded = async () => {
|
|||||||
const apiTokenCount = await apiTokenService.count();
|
const apiTokenCount = await apiTokenService.count();
|
||||||
|
|
||||||
if (usersCount === 0 && apiTokenCount === 0) {
|
if (usersCount === 0 && apiTokenCount === 0) {
|
||||||
await apiTokenService.create({
|
for (const token of constants.DEFAULT_API_TOKENS) {
|
||||||
name: 'Read Only',
|
await apiTokenService.create(token);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import path from 'path';
|
|||||||
import { map, values, sumBy, pipe, flatMap, propEq } from 'lodash/fp';
|
import { map, values, sumBy, pipe, flatMap, propEq } from 'lodash/fp';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { exists } from 'fs-extra';
|
import { exists } from 'fs-extra';
|
||||||
import '@strapi/types';
|
|
||||||
import { env } from '@strapi/utils';
|
import { env } from '@strapi/utils';
|
||||||
import tsUtils from '@strapi/typescript-utils';
|
import tsUtils from '@strapi/typescript-utils';
|
||||||
import {
|
import {
|
||||||
@ -22,6 +21,7 @@ import type {
|
|||||||
Plugins,
|
Plugins,
|
||||||
TelemetryProperties,
|
TelemetryProperties,
|
||||||
UpdateProjectSettings,
|
UpdateProjectSettings,
|
||||||
|
GetGuidedTourMeta,
|
||||||
} from '../../../shared/contracts/admin';
|
} from '../../../shared/contracts/admin';
|
||||||
|
|
||||||
const { isUsingTypeScript } = tsUtils;
|
const { isUsingTypeScript } = tsUtils;
|
||||||
@ -185,4 +185,18 @@ export default {
|
|||||||
|
|
||||||
return data;
|
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'],
|
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_30: 30 * DAY_IN_MS,
|
||||||
DAYS_90: 90 * 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: {
|
TRANSFER_TOKEN_TYPE: {
|
||||||
PUSH: 'push',
|
PUSH: 'push',
|
||||||
PULL: 'pull',
|
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 apiToken from './api-token';
|
||||||
import * as transfer from './transfer';
|
import * as transfer from './transfer';
|
||||||
import * as projectSettings from './project-settings';
|
import * as projectSettings from './project-settings';
|
||||||
|
import { createGuidedTourService } from './guided-tour';
|
||||||
|
|
||||||
// TODO: TS - Export services one by one as this export is cjs
|
// TODO: TS - Export services one by one as this export is cjs
|
||||||
export default {
|
export default {
|
||||||
@ -32,4 +33,5 @@ export default {
|
|||||||
transfer,
|
transfer,
|
||||||
'project-settings': projectSettings,
|
'project-settings': projectSettings,
|
||||||
encryption,
|
encryption,
|
||||||
|
'guided-tour': createGuidedTourService,
|
||||||
};
|
};
|
||||||
|
@ -161,6 +161,31 @@ const isLastSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
|
|||||||
return superAdminRole.usersCount === 1 && hasSuperAdminRole(user);
|
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
|
* Check if a user with specific attributes exists in the database
|
||||||
* @param attributes A partial user object
|
* @param attributes A partial user object
|
||||||
@ -390,4 +415,5 @@ export default {
|
|||||||
displayWarningIfUsersDontHaveRole,
|
displayWarningIfUsersDontHaveRole,
|
||||||
resetPasswordByEmail,
|
resetPasswordByEmail,
|
||||||
getLanguagesInUse,
|
getLanguagesInUse,
|
||||||
|
isFirstSuperAdminUser,
|
||||||
};
|
};
|
||||||
|
@ -10,6 +10,7 @@ import * as token from '../services/token';
|
|||||||
import * as apiToken from '../services/api-token';
|
import * as apiToken from '../services/api-token';
|
||||||
import * as projectSettings from '../services/project-settings';
|
import * as projectSettings from '../services/project-settings';
|
||||||
import * as transfer from '../services/transfer';
|
import * as transfer from '../services/transfer';
|
||||||
|
import { createGuidedTourService } from '../services/guided-tour';
|
||||||
|
|
||||||
type S = {
|
type S = {
|
||||||
role: typeof role;
|
role: typeof role;
|
||||||
@ -24,6 +25,7 @@ type S = {
|
|||||||
'project-settings': typeof projectSettings;
|
'project-settings': typeof projectSettings;
|
||||||
transfer: typeof transfer;
|
transfer: typeof transfer;
|
||||||
encryption: typeof encryption;
|
encryption: typeof encryption;
|
||||||
|
'guided-tour': ReturnType<typeof createGuidedTourService>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
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 { errors } from '@strapi/utils';
|
||||||
import type { File } from 'formidable';
|
import type { File } from 'formidable';
|
||||||
|
import type { GuidedTourCompletedActions } from '../../server/src/services/guided-tour';
|
||||||
|
|
||||||
export interface Logo {
|
export interface Logo {
|
||||||
name: string;
|
name: string;
|
||||||
@ -218,3 +220,18 @@ export declare namespace GetLicenseLimitInformation {
|
|||||||
error?: errors.ApplicationError;
|
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 * 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 { Box, Divider, Flex, Grid, Typography } from '@strapi/design-system';
|
||||||
import pipe from 'lodash/fp/pipe';
|
import pipe from 'lodash/fp/pipe';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@ -103,9 +103,19 @@ function getRemaingFieldsLayout({
|
|||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
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'))) {
|
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||||
const [row] = panel;
|
const [row] = panel;
|
||||||
const [field] = row;
|
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 (
|
return (
|
||||||
<Grid.Root key={field.name} gap={4}>
|
<Grid.Root key={field.name} gap={4}>
|
||||||
@ -128,9 +138,24 @@ const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
|||||||
borderColor="neutral150"
|
borderColor="neutral150"
|
||||||
>
|
>
|
||||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||||
{panel.map((row, gridRowIndex) => (
|
{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}>
|
<Grid.Root key={gridRowIndex} gap={4}>
|
||||||
{row.map(({ size, ...field }) => {
|
{visibleFields.map(({ size, ...field }) => {
|
||||||
return (
|
return (
|
||||||
<Grid.Item
|
<Grid.Item
|
||||||
col={size}
|
col={size}
|
||||||
@ -145,7 +170,8 @@ const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid.Root>
|
</Grid.Root>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
useRBAC,
|
useRBAC,
|
||||||
useNotification,
|
useNotification,
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
|
unstable_tours,
|
||||||
} from '@strapi/admin/strapi-admin';
|
} from '@strapi/admin/strapi-admin';
|
||||||
import { Grid, Main, Tabs } from '@strapi/design-system';
|
import { Grid, Main, Tabs } from '@strapi/design-system';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@ -27,6 +28,7 @@ import { createYupSchema } from '../../utils/validation';
|
|||||||
import { FormLayout } from './components/FormLayout';
|
import { FormLayout } from './components/FormLayout';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Panels } from './components/Panels';
|
import { Panels } from './components/Panels';
|
||||||
|
import { handleInvisibleAttributes } from './utils/data';
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------------------------------
|
||||||
* EditViewPage
|
* EditViewPage
|
||||||
@ -143,12 +145,22 @@ const EditViewPage = () => {
|
|||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
method={isCreatingDocument ? 'POST' : 'PUT'}
|
method={isCreatingDocument ? 'POST' : 'PUT'}
|
||||||
validate={(values: Record<string, unknown>, options: Record<string, string>) => {
|
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, {
|
const yupSchema = createYupSchema(schema?.attributes, components, {
|
||||||
status,
|
status,
|
||||||
|
removedAttributes,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
return yupSchema.validate(values, { abortEarly: false });
|
return yupSchema.validate(cleanedValues, { abortEarly: false });
|
||||||
}}
|
}}
|
||||||
initialErrors={location?.state?.forceValidation ? validateSync(initialValues, {}) : {}}
|
initialErrors={location?.state?.forceValidation ? validateSync(initialValues, {}) : {}}
|
||||||
>
|
>
|
||||||
@ -187,9 +199,11 @@ const EditViewPage = () => {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Grid.Root paddingTop={8} gap={4}>
|
<Grid.Root paddingTop={8} gap={4}>
|
||||||
<Grid.Item col={9} s={12} direction="column" alignItems="stretch">
|
<Grid.Item col={9} s={12} direction="column" alignItems="stretch">
|
||||||
|
<unstable_tours.contentManager.Fields>
|
||||||
<Tabs.Content value="draft">
|
<Tabs.Content value="draft">
|
||||||
<FormLayout layout={layout} document={doc} />
|
<FormLayout layout={layout} document={doc} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
</unstable_tours.contentManager.Fields>
|
||||||
<Tabs.Content value="published">
|
<Tabs.Content value="published">
|
||||||
<FormLayout layout={layout} document={doc} />
|
<FormLayout layout={layout} document={doc} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
NotificationConfig,
|
NotificationConfig,
|
||||||
useAPIErrorHandler,
|
useAPIErrorHandler,
|
||||||
useQueryParams,
|
useQueryParams,
|
||||||
|
unstable_tours,
|
||||||
} from '@strapi/admin/strapi-admin';
|
} from '@strapi/admin/strapi-admin';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -41,7 +42,7 @@ import {
|
|||||||
} from '../../../services/documents';
|
} from '../../../services/documents';
|
||||||
import { isBaseQueryError, buildValidParams } from '../../../utils/api';
|
import { isBaseQueryError, buildValidParams } from '../../../utils/api';
|
||||||
import { getTranslation } from '../../../utils/translations';
|
import { getTranslation } from '../../../utils/translations';
|
||||||
import { AnyData } from '../utils/data';
|
import { AnyData, handleInvisibleAttributes } from '../utils/data';
|
||||||
|
|
||||||
import { useRelationModal } from './FormInputs/Relations/RelationModal';
|
import { useRelationModal } from './FormInputs/Relations/RelationModal';
|
||||||
|
|
||||||
@ -177,8 +178,14 @@ const DocumentActions = ({ actions }: DocumentActionsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap={2} alignItems="stretch" width="100%">
|
<Flex direction="column" gap={2} alignItems="stretch" width="100%">
|
||||||
|
<unstable_tours.contentManager.Publish>
|
||||||
<Flex gap={2}>
|
<Flex gap={2}>
|
||||||
|
{primaryAction.label === 'Publish' ? (
|
||||||
<DocumentActionButton {...primaryAction} variant={primaryAction.variant || 'default'} />
|
<DocumentActionButton {...primaryAction} variant={primaryAction.variant || 'default'} />
|
||||||
|
) : (
|
||||||
|
<DocumentActionButton {...primaryAction} variant={primaryAction.variant || 'default'} />
|
||||||
|
)}
|
||||||
|
|
||||||
{restActions.length > 0 ? (
|
{restActions.length > 0 ? (
|
||||||
<DocumentActionsMenu
|
<DocumentActionsMenu
|
||||||
actions={restActions}
|
actions={restActions}
|
||||||
@ -189,11 +196,21 @@ const DocumentActions = ({ actions }: DocumentActionsProps) => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</unstable_tours.contentManager.Publish>
|
||||||
{secondaryAction ? (
|
{secondaryAction ? (
|
||||||
|
secondaryAction.label === 'Publish' ? (
|
||||||
|
<unstable_tours.contentManager.Publish>
|
||||||
<DocumentActionButton
|
<DocumentActionButton
|
||||||
{...secondaryAction}
|
{...secondaryAction}
|
||||||
variant={secondaryAction.variant || 'secondary'}
|
variant={secondaryAction.variant || 'secondary'}
|
||||||
/>
|
/>
|
||||||
|
</unstable_tours.contentManager.Publish>
|
||||||
|
) : (
|
||||||
|
<DocumentActionButton
|
||||||
|
{...secondaryAction}
|
||||||
|
variant={secondaryAction.variant || 'secondary'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
@ -555,6 +572,9 @@ const PublishAction: DocumentActionComponent = ({
|
|||||||
const setErrors = useForm('PublishAction', (state) => state.setErrors);
|
const setErrors = useForm('PublishAction', (state) => state.setErrors);
|
||||||
const formValues = useForm('PublishAction', ({ values }) => values);
|
const formValues = useForm('PublishAction', ({ values }) => values);
|
||||||
const resetForm = useForm('PublishAction', ({ resetForm }) => resetForm);
|
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
|
// need to discriminate if the publish is coming from a relation modal or in the edit view
|
||||||
const relationContext = useRelationModal('PublishAction', () => true, false);
|
const relationContext = useRelationModal('PublishAction', () => true, false);
|
||||||
@ -722,6 +742,10 @@ const PublishAction: DocumentActionComponent = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { data } = handleInvisibleAttributes(transformData(formValues), {
|
||||||
|
schema,
|
||||||
|
components,
|
||||||
|
});
|
||||||
const res = await publish(
|
const res = await publish(
|
||||||
{
|
{
|
||||||
collectionType,
|
collectionType,
|
||||||
@ -729,7 +753,7 @@ const PublishAction: DocumentActionComponent = ({
|
|||||||
documentId,
|
documentId,
|
||||||
params: currentDocumentMeta.params,
|
params: currentDocumentMeta.params,
|
||||||
},
|
},
|
||||||
transformData(formValues)
|
data
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset form if successful
|
// Reset form if successful
|
||||||
@ -909,6 +933,9 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
const isCloning = cloneMatch !== null;
|
const isCloning = cloneMatch !== null;
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { create, update, clone, isLoading } = useDocumentActions();
|
const { create, update, clone, isLoading } = useDocumentActions();
|
||||||
|
const {
|
||||||
|
currentDocument: { components },
|
||||||
|
} = useDocumentContext('UpdateAction');
|
||||||
const [{ rawQuery }] = useQueryParams();
|
const [{ rawQuery }] = useQueryParams();
|
||||||
const onPreview = usePreviewContext('UpdateAction', (state) => state.onPreview, false);
|
const onPreview = usePreviewContext('UpdateAction', (state) => state.onPreview, false);
|
||||||
const { getInitialFormValues } = useDoc();
|
const { getInitialFormValues } = useDoc();
|
||||||
@ -916,6 +943,7 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
const isSubmitting = useForm('UpdateAction', ({ isSubmitting }) => isSubmitting);
|
const isSubmitting = useForm('UpdateAction', ({ isSubmitting }) => isSubmitting);
|
||||||
const modified = useForm('UpdateAction', ({ modified }) => modified);
|
const modified = useForm('UpdateAction', ({ modified }) => modified);
|
||||||
const setSubmitting = useForm('UpdateAction', ({ setSubmitting }) => setSubmitting);
|
const setSubmitting = useForm('UpdateAction', ({ setSubmitting }) => setSubmitting);
|
||||||
|
const initialValues = useForm('UpdateAction', ({ initialValues }) => initialValues);
|
||||||
const document = useForm('UpdateAction', ({ values }) => values);
|
const document = useForm('UpdateAction', ({ values }) => values);
|
||||||
const validate = useForm('UpdateAction', (state) => state.validate);
|
const validate = useForm('UpdateAction', (state) => state.validate);
|
||||||
const setErrors = useForm('UpdateAction', (state) => state.setErrors);
|
const setErrors = useForm('UpdateAction', (state) => state.setErrors);
|
||||||
@ -925,6 +953,11 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
|
|
||||||
// need to discriminate if the update is coming from a relation modal or in the edit view
|
// need to discriminate if the update is coming from a relation modal or in the edit view
|
||||||
const relationContext = useRelationModal('UpdateAction', () => true, false);
|
const relationContext = useRelationModal('UpdateAction', () => true, false);
|
||||||
|
const relationalModalSchema = useRelationModal(
|
||||||
|
'UpdateAction',
|
||||||
|
(state) => state.currentDocument.schema,
|
||||||
|
false
|
||||||
|
);
|
||||||
const fieldToConnect = useRelationModal(
|
const fieldToConnect = useRelationModal(
|
||||||
'UpdateAction',
|
'UpdateAction',
|
||||||
(state) => state.state.fieldToConnect,
|
(state) => state.state.fieldToConnect,
|
||||||
@ -956,6 +989,7 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
},
|
},
|
||||||
{ skip: !parentDocumentMetaToUpdate }
|
{ skip: !parentDocumentMetaToUpdate }
|
||||||
);
|
);
|
||||||
|
const { schema } = useDoc();
|
||||||
|
|
||||||
const handleUpdate = React.useCallback(async () => {
|
const handleUpdate = React.useCallback(async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -981,7 +1015,6 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCloning) {
|
if (isCloning) {
|
||||||
const res = await clone(
|
const res = await clone(
|
||||||
{
|
{
|
||||||
@ -1008,6 +1041,11 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
setErrors(formatValidationErrors(res.error));
|
setErrors(formatValidationErrors(res.error));
|
||||||
}
|
}
|
||||||
} else if (documentId || collectionType === SINGLE_TYPES) {
|
} else if (documentId || collectionType === SINGLE_TYPES) {
|
||||||
|
const { data } = handleInvisibleAttributes(transformData(document), {
|
||||||
|
schema: fromRelationModal ? relationalModalSchema : schema,
|
||||||
|
initialValues,
|
||||||
|
components,
|
||||||
|
});
|
||||||
const res = await update(
|
const res = await update(
|
||||||
{
|
{
|
||||||
collectionType,
|
collectionType,
|
||||||
@ -1015,7 +1053,7 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
documentId,
|
documentId,
|
||||||
params: currentDocumentMeta.params,
|
params: currentDocumentMeta.params,
|
||||||
},
|
},
|
||||||
transformData(document)
|
data
|
||||||
);
|
);
|
||||||
|
|
||||||
if ('error' in res && isBaseQueryError(res.error) && res.error.name === 'ValidationError') {
|
if ('error' in res && isBaseQueryError(res.error) && res.error.name === 'ValidationError') {
|
||||||
@ -1024,12 +1062,17 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const { data } = handleInvisibleAttributes(transformData(document), {
|
||||||
|
schema: fromRelationModal ? relationalModalSchema : schema,
|
||||||
|
initialValues,
|
||||||
|
components,
|
||||||
|
});
|
||||||
const res = await create(
|
const res = await create(
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
params: currentDocumentMeta.params,
|
params: currentDocumentMeta.params,
|
||||||
},
|
},
|
||||||
transformData(document)
|
data
|
||||||
);
|
);
|
||||||
|
|
||||||
if ('data' in res && collectionType !== SINGLE_TYPES) {
|
if ('data' in res && collectionType !== SINGLE_TYPES) {
|
||||||
@ -1152,6 +1195,10 @@ const UpdateAction: DocumentActionComponent = ({
|
|||||||
updateDocumentMutation,
|
updateDocumentMutation,
|
||||||
formatAPIError,
|
formatAPIError,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
initialValues,
|
||||||
|
schema,
|
||||||
|
components,
|
||||||
|
relationalModalSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-save on CMD+S or CMD+Enter on macOS, and CTRL+S or CTRL+Enter on Windows/Linux
|
// 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 { Box, Flex } from '@strapi/design-system';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ const NonRepeatableComponent = ({
|
|||||||
const level = useComponent('NonRepeatableComponent', (state) => state.level);
|
const level = useComponent('NonRepeatableComponent', (state) => state.level);
|
||||||
const isNested = level > 0;
|
const isNested = level > 0;
|
||||||
const { currentDocument } = useDocumentContext('NonRepeatableComponent');
|
const { currentDocument } = useDocumentContext('NonRepeatableComponent');
|
||||||
|
const rulesEngine = createRulesEngine();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComponentProvider id={value?.id} uid={attribute.component} level={level + 1} type="component">
|
<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}>
|
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||||
{layout.map((row, index) => {
|
{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 (
|
return (
|
||||||
<ResponsiveGridRoot gap={4} key={index}>
|
<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
|
* 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
|
* schema tree, for components we append the parent name to the field name
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
@ -67,6 +67,8 @@ const RepeatableComponent = ({
|
|||||||
const [collapseToOpen, setCollapseToOpen] = React.useState<string>('');
|
const [collapseToOpen, setCollapseToOpen] = React.useState<string>('');
|
||||||
const [liveText, setLiveText] = React.useState('');
|
const [liveText, setLiveText] = React.useState('');
|
||||||
|
|
||||||
|
const rulesEngine = createRulesEngine();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hasNestedErrors = rawError && Array.isArray(rawError) && rawError.length > 0;
|
const hasNestedErrors = rawError && Array.isArray(rawError) && rawError.length > 0;
|
||||||
const hasNestedValue = value && Array.isArray(value) && value.length > 0;
|
const hasNestedValue = value && Array.isArray(value) && value.length > 0;
|
||||||
@ -244,8 +246,9 @@ const RepeatableComponent = ({
|
|||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
aria-describedby={ariaDescriptionId}
|
aria-describedby={ariaDescriptionId}
|
||||||
>
|
>
|
||||||
{value.map(({ __temp_key__: key, id }, index) => {
|
{value.map(({ __temp_key__: key, id, ...currentComponentValues }, index) => {
|
||||||
const nameWithIndex = `${name}.${index}`;
|
const nameWithIndex = `${name}.${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComponentProvider
|
<ComponentProvider
|
||||||
key={key}
|
key={key}
|
||||||
@ -273,9 +276,21 @@ const RepeatableComponent = ({
|
|||||||
__temp_key__={key}
|
__temp_key__={key}
|
||||||
>
|
>
|
||||||
{layout.map((row, index) => {
|
{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 (
|
return (
|
||||||
<ResponsiveGridRoot gap={4} key={index}>
|
<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
|
* 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
|
* schema tree, for components we append the parent name to the field name
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { useForm, useField } from '@strapi/admin/strapi-admin';
|
import { useForm, useField, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
Box,
|
Box,
|
||||||
@ -58,6 +58,7 @@ const DynamicComponent = ({
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const formValues = useForm('DynamicComponent', (state) => state.values);
|
const formValues = useForm('DynamicComponent', (state) => state.values);
|
||||||
const { currentDocument, currentDocumentMeta } = useDocumentContext('DynamicComponent');
|
const { currentDocument, currentDocumentMeta } = useDocumentContext('DynamicComponent');
|
||||||
|
const rulesEngine = createRulesEngine();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
edit: { components },
|
edit: { components },
|
||||||
@ -238,7 +239,21 @@ const DynamicComponent = ({
|
|||||||
<AccordionContentRadius background="neutral0">
|
<AccordionContentRadius background="neutral0">
|
||||||
<Box paddingLeft={6} paddingRight={6} paddingTop={6} paddingBottom={6}>
|
<Box paddingLeft={6} paddingRight={6} paddingTop={6} paddingBottom={6}>
|
||||||
<Grid.Root gap={4}>
|
<Grid.Root gap={4}>
|
||||||
{components[componentUid]?.layout?.map((row, rowInd) => (
|
{components[componentUid]?.layout?.map((row, rowInd) => {
|
||||||
|
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 (
|
||||||
<Grid.Item
|
<Grid.Item
|
||||||
col={12}
|
col={12}
|
||||||
key={rowInd}
|
key={rowInd}
|
||||||
@ -248,7 +263,7 @@ const DynamicComponent = ({
|
|||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
>
|
>
|
||||||
<ResponsiveGridRoot gap={4}>
|
<ResponsiveGridRoot gap={4}>
|
||||||
{row.map(({ size, ...field }) => {
|
{visibleFields.map(({ size, ...field }) => {
|
||||||
const fieldName = `${name}.${index}.${field.name}`;
|
const fieldName = `${name}.${index}.${field.name}`;
|
||||||
|
|
||||||
const fieldWithTranslatedLabel = {
|
const fieldWithTranslatedLabel = {
|
||||||
@ -286,7 +301,8 @@ const DynamicComponent = ({
|
|||||||
})}
|
})}
|
||||||
</ResponsiveGridRoot>
|
</ResponsiveGridRoot>
|
||||||
</Grid.Item>
|
</Grid.Item>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Grid.Root>
|
</Grid.Root>
|
||||||
</Box>
|
</Box>
|
||||||
</AccordionContentRadius>
|
</AccordionContentRadius>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { useForm, createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||||
import { Box, BoxProps, Flex, Grid } from '@strapi/design-system';
|
import { Box, BoxProps, Flex, Grid } from '@strapi/design-system';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
@ -52,6 +53,8 @@ interface FormLayoutProps extends Pick<EditLayout, 'layout'> {
|
|||||||
const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps) => {
|
const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const modelUid = document.schema?.uid;
|
const modelUid = document.schema?.uid;
|
||||||
|
const fieldValues = useForm('Fields', (state) => state.values);
|
||||||
|
const rulesEngine = createRulesEngine();
|
||||||
|
|
||||||
const getLabel = (name: string, label: string) => {
|
const getLabel = (name: string, label: string) => {
|
||||||
return formatMessage({
|
return formatMessage({
|
||||||
@ -66,6 +69,15 @@ const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps)
|
|||||||
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||||
const [row] = panel;
|
const [row] = panel;
|
||||||
const [field] = row;
|
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 (
|
return (
|
||||||
<Grid.Root key={field.name} gap={4}>
|
<Grid.Root key={field.name} gap={4}>
|
||||||
@ -83,9 +95,25 @@ const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps)
|
|||||||
return (
|
return (
|
||||||
<Box key={index} {...(hasBackground && panelStyles)}>
|
<Box key={index} {...(hasBackground && panelStyles)}>
|
||||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||||
{panel.map((row, gridRowIndex) => (
|
{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}>
|
<ResponsiveGridRoot key={gridRowIndex} gap={4}>
|
||||||
{row.map(({ size, ...field }) => {
|
{visibleFields.map(({ size, ...field }) => {
|
||||||
return (
|
return (
|
||||||
<ResponsiveGridItem
|
<ResponsiveGridItem
|
||||||
col={size}
|
col={size}
|
||||||
@ -104,7 +132,8 @@ const FormLayout = ({ layout, document, hasBackground = true }: FormLayoutProps)
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ResponsiveGridRoot>
|
</ResponsiveGridRoot>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { createRulesEngine } from '@strapi/admin/strapi-admin';
|
||||||
import { generateNKeysBetween } from 'fractional-indexing';
|
import { generateNKeysBetween } from 'fractional-indexing';
|
||||||
import pipe from 'lodash/fp/pipe';
|
import pipe from 'lodash/fp/pipe';
|
||||||
|
|
||||||
@ -210,11 +211,147 @@ const transformDocument =
|
|||||||
return transformations(document);
|
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 {
|
export {
|
||||||
removeProhibitedFields,
|
removeProhibitedFields,
|
||||||
prepareRelations,
|
prepareRelations,
|
||||||
prepareTempKeys,
|
prepareTempKeys,
|
||||||
removeFieldsThatDontExistOnSchema,
|
removeFieldsThatDontExistOnSchema,
|
||||||
transformDocument,
|
transformDocument,
|
||||||
|
handleInvisibleAttributes,
|
||||||
};
|
};
|
||||||
export type { AnyData };
|
export type { AnyData };
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
useRBAC,
|
useRBAC,
|
||||||
Layouts,
|
Layouts,
|
||||||
useTable,
|
useTable,
|
||||||
|
unstable_tours,
|
||||||
} from '@strapi/admin/strapi-admin';
|
} from '@strapi/admin/strapi-admin';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@ -228,6 +229,11 @@ const ListViewPage = () => {
|
|||||||
|
|
||||||
if (!isFetching && results.length === 0) {
|
if (!isFetching && results.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<unstable_tours.contentManager.Introduction>
|
||||||
|
{/* Invisible Anchor */}
|
||||||
|
<Box paddingTop={5} />
|
||||||
|
</unstable_tours.contentManager.Introduction>
|
||||||
<Page.Main>
|
<Page.Main>
|
||||||
<Page.Title>{`${contentTypeTitle}`}</Page.Title>
|
<Page.Title>{`${contentTypeTitle}`}</Page.Title>
|
||||||
<LayoutsHeaderCustom
|
<LayoutsHeaderCustom
|
||||||
@ -290,6 +296,7 @@ const ListViewPage = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Layouts.Content>
|
</Layouts.Content>
|
||||||
</Page.Main>
|
</Page.Main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ const contentManagerApi = adminApi.enhanceEndpoints({
|
|||||||
'Relations',
|
'Relations',
|
||||||
'UidAvailability',
|
'UidAvailability',
|
||||||
'RecentDocumentList',
|
'RecentDocumentList',
|
||||||
|
'GuidedTourMeta',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -334,6 +334,7 @@ const documentApi = contentManagerApi.injectEndpoints({
|
|||||||
{ type: 'Document', id: `${model}_LIST` },
|
{ type: 'Document', id: `${model}_LIST` },
|
||||||
'Relations',
|
'Relations',
|
||||||
'RecentDocumentList',
|
'RecentDocumentList',
|
||||||
|
'GuidedTourMeta',
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -205,5 +205,10 @@
|
|||||||
"history.restore.confirm.title": "Êtes-vous sûr de vouloir restaurer cette version ?",
|
"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.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.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 {
|
interface ValidationOptions {
|
||||||
status: 'draft' | 'published' | null;
|
status: 'draft' | 'published' | null;
|
||||||
|
removedAttributes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayValidator = (attribute: Schema['attributes'][string], options: ValidationOptions) => ({
|
const arrayValidator = (attribute: Schema['attributes'][string], options: ValidationOptions) => ({
|
||||||
@ -46,7 +47,7 @@ const arrayValidator = (attribute: Schema['attributes'][string], options: Valida
|
|||||||
return true;
|
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?
|
* TODO: should we create a Map to store these based on the hash of the schema?
|
||||||
*/
|
*/
|
||||||
@ -55,15 +56,36 @@ const createYupSchema = (
|
|||||||
components: ComponentsDictionary = {},
|
components: ComponentsDictionary = {},
|
||||||
options: ValidationOptions = { status: null }
|
options: ValidationOptions = { status: null }
|
||||||
): yup.ObjectSchema<any> => {
|
): yup.ObjectSchema<any> => {
|
||||||
const createModelSchema = (attributes: Schema['attributes']): yup.ObjectSchema<any> =>
|
const createModelSchema = (
|
||||||
|
attributes: Schema['attributes'],
|
||||||
|
removedAttributes: string[] = []
|
||||||
|
): yup.ObjectSchema<any> =>
|
||||||
yup
|
yup
|
||||||
.object()
|
.object()
|
||||||
.shape(
|
.shape(
|
||||||
Object.entries(attributes).reduce<ObjectShape>((acc, [name, attribute]) => {
|
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)) {
|
if (DOCUMENT_META_FIELDS.includes(name)) {
|
||||||
return acc;
|
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
|
* These validations won't apply to every attribute
|
||||||
* and that's okay, in that case we just return the
|
* and that's okay, in that case we just return the
|
||||||
@ -89,13 +111,13 @@ const createYupSchema = (
|
|||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[name]: transformSchema(
|
[name]: transformSchema(
|
||||||
yup.array().of(createModelSchema(attributes).nullable(false))
|
yup.array().of(createModelSchema(attributes, nestedRemoved).nullable(false))
|
||||||
).test(arrayValidator(attribute, options)),
|
).test(arrayValidator(attribute, options)),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[name]: transformSchema(createModelSchema(attributes).nullable()),
|
[name]: transformSchema(createModelSchema(attributes, nestedRemoved).nullable()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,7 +142,7 @@ const createYupSchema = (
|
|||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return validation.concat(createModelSchema(attributes));
|
return validation.concat(createModelSchema(attributes, nestedRemoved));
|
||||||
}
|
}
|
||||||
) as unknown as yup.ObjectSchema<any>
|
) as unknown as yup.ObjectSchema<any>
|
||||||
)
|
)
|
||||||
@ -171,7 +193,7 @@ const createYupSchema = (
|
|||||||
*/
|
*/
|
||||||
.default(null);
|
.default(null);
|
||||||
|
|
||||||
return createModelSchema(attributes);
|
return createModelSchema(attributes, options.removedAttributes);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createAttributeSchema = (
|
const createAttributeSchema = (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/content-manager",
|
"name": "@strapi/content-manager",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "A powerful UI to easily manage your data.",
|
"description": "A powerful UI to easily manage your data.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -67,10 +67,10 @@
|
|||||||
"@radix-ui/react-toolbar": "1.0.4",
|
"@radix-ui/react-toolbar": "1.0.4",
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@sindresorhus/slugify": "1.1.0",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
"@strapi/design-system": "2.0.0-rc.27",
|
"@strapi/design-system": "2.0.0-rc.28",
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"@strapi/types": "5.16.1",
|
"@strapi/types": "5.17.0",
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"codemirror5": "npm:codemirror@^5.65.11",
|
"codemirror5": "npm:codemirror@^5.65.11",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"fractional-indexing": "3.2.0",
|
"fractional-indexing": "3.2.0",
|
||||||
@ -105,8 +105,8 @@
|
|||||||
"yup": "0.32.9"
|
"yup": "0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@strapi/admin": "5.16.1",
|
"@strapi/admin": "5.17.0",
|
||||||
"@strapi/database": "5.16.1",
|
"@strapi/database": "5.17.0",
|
||||||
"@testing-library/react": "15.0.7",
|
"@testing-library/react": "15.0.7",
|
||||||
"@types/jest": "29.5.2",
|
"@types/jest": "29.5.2",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
@ -35,8 +35,8 @@ const sanitizeMainField = (model: any, mainField: any, userAbility: any) => {
|
|||||||
const canReadMainField = permissionChecker.can.read(null, mainField);
|
const canReadMainField = permissionChecker.can.read(null, mainField);
|
||||||
|
|
||||||
if (!isMainFieldListable || !canReadMainField) {
|
if (!isMainFieldListable || !canReadMainField) {
|
||||||
// Default to 'id' if the actual main field shouldn't be displayed
|
// Default to 'documentId' if the actual main field shouldn't be displayed
|
||||||
return 'id';
|
return 'documentId';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge cases
|
// Edge cases
|
||||||
|
@ -285,7 +285,9 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|||||||
const otherStatus = await this.getManyAvailableStatus(uid, document.localizations);
|
const otherStatus = await this.getManyAvailableStatus(uid, document.localizations);
|
||||||
|
|
||||||
document.localizations = document.localizations.map((d) => {
|
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 {
|
return {
|
||||||
...d,
|
...d,
|
||||||
status: this.getStatus(d, status ? [status] : []),
|
status: this.getStatus(d, status ? [status] : []),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/content-releases",
|
"name": "@strapi/content-releases",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Strapi plugin for organizing and releasing content",
|
"description": "Strapi plugin for organizing and releasing content",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -59,11 +59,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@strapi/database": "5.16.1",
|
"@strapi/database": "5.17.0",
|
||||||
"@strapi/design-system": "2.0.0-rc.27",
|
"@strapi/design-system": "2.0.0-rc.28",
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"@strapi/types": "5.16.1",
|
"@strapi/types": "5.17.0",
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"date-fns-tz": "2.0.1",
|
"date-fns-tz": "2.0.1",
|
||||||
"formik": "2.4.5",
|
"formik": "2.4.5",
|
||||||
@ -75,9 +75,9 @@
|
|||||||
"yup": "0.32.9"
|
"yup": "0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@strapi/admin": "5.16.1",
|
"@strapi/admin": "5.17.0",
|
||||||
"@strapi/admin-test-utils": "5.16.1",
|
"@strapi/admin-test-utils": "5.17.0",
|
||||||
"@strapi/content-manager": "5.16.1",
|
"@strapi/content-manager": "5.17.0",
|
||||||
"@testing-library/dom": "10.1.0",
|
"@testing-library/dom": "10.1.0",
|
||||||
"@testing-library/react": "15.0.7",
|
"@testing-library/react": "15.0.7",
|
||||||
"@testing-library/user-event": "14.5.2",
|
"@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 { 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 { ChevronDown, Drag, Lock, Pencil, Trash } from '@strapi/icons';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import upperFirst from 'lodash/upperFirst';
|
import upperFirst from 'lodash/upperFirst';
|
||||||
@ -9,6 +10,7 @@ import { Link as NavLink } from 'react-router-dom';
|
|||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
|
|
||||||
import { Curve } from '../icons/Curve';
|
import { Curve } from '../icons/Curve';
|
||||||
|
import { checkDependentRows } from '../utils/conditions';
|
||||||
import { getAttributeDisplayedType } from '../utils/getAttributeDisplayedType';
|
import { getAttributeDisplayedType } from '../utils/getAttributeDisplayedType';
|
||||||
import { getRelationType } from '../utils/getRelationType';
|
import { getRelationType } from '../utils/getRelationType';
|
||||||
import { getTrad } from '../utils/getTrad';
|
import { getTrad } from '../utils/getTrad';
|
||||||
@ -102,6 +104,7 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
|||||||
const { onOpenModalEditField, onOpenModalEditCustomField } = useFormModalNavigation();
|
const { onOpenModalEditField, onOpenModalEditCustomField } = useFormModalNavigation();
|
||||||
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
const isDeleted = item.status === 'REMOVED';
|
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 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 = () => {
|
const handleClick = () => {
|
||||||
if (isMorph) {
|
if (isMorph) {
|
||||||
return;
|
return;
|
||||||
@ -217,6 +247,9 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
|||||||
repeatable={'repeatable' in item && item.repeatable}
|
repeatable={'repeatable' in item && item.repeatable}
|
||||||
multiple={'multiple' in item && item.multiple}
|
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' && (
|
{item.type === 'relation' && (
|
||||||
<>
|
<>
|
||||||
({getRelationType(item.relation, item.targetAttribute)})
|
({getRelationType(item.relation, item.targetAttribute)})
|
||||||
@ -298,14 +331,7 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={(e) => {
|
onClick={handleDelete}
|
||||||
e.stopPropagation();
|
|
||||||
removeAttribute({
|
|
||||||
forTarget: type.modelType,
|
|
||||||
targetUid: type.uid,
|
|
||||||
attributeToRemoveName: item.name,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
label={`${formatMessage({
|
label={`${formatMessage({
|
||||||
id: 'global.delete',
|
id: 'global.delete',
|
||||||
defaultMessage: 'Delete',
|
defaultMessage: 'Delete',
|
||||||
@ -315,6 +341,32 @@ const MemoizedRow = memo((props: Omit<AttributeRowProps, 'style'>) => {
|
|||||||
>
|
>
|
||||||
<Trash />
|
<Trash />
|
||||||
</IconButton>
|
</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}>
|
<Flex padding={2}>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -2,11 +2,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Layouts } from '@strapi/admin/strapi-admin';
|
import { Layouts } from '@strapi/admin/strapi-admin';
|
||||||
import { DesignSystemProvider } from '@strapi/design-system';
|
import { render, screen } from '@strapi/admin/strapi-admin/test';
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { userEvent } from '@testing-library/user-event';
|
import { userEvent } from '@testing-library/user-event';
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useDataManager } from '../../DataManager/useDataManager';
|
import { useDataManager } from '../../DataManager/useDataManager';
|
||||||
import { ContentTypeBuilderNav } from '../ContentTypeBuilderNav';
|
import { ContentTypeBuilderNav } from '../ContentTypeBuilderNav';
|
||||||
@ -58,19 +55,11 @@ jest.mock('../../DataManager/useDataManager.ts', () => {
|
|||||||
|
|
||||||
const mockedUseDataManager = jest.mocked(useDataManager);
|
const mockedUseDataManager = jest.mocked(useDataManager);
|
||||||
|
|
||||||
const makeApp = () => {
|
const App = (
|
||||||
return (
|
|
||||||
<IntlProvider messages={{}} defaultLocale="en" textComponent="span" locale="en">
|
|
||||||
<DesignSystemProvider>
|
|
||||||
<MemoryRouter>
|
|
||||||
<Layouts.Root sideNav={<ContentTypeBuilderNav />}>
|
<Layouts.Root sideNav={<ContentTypeBuilderNav />}>
|
||||||
<div />
|
<div />
|
||||||
</Layouts.Root>
|
</Layouts.Root>
|
||||||
</MemoryRouter>
|
);
|
||||||
</DesignSystemProvider>
|
|
||||||
</IntlProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('<ContentTypeBuilderNav />', () => {
|
describe('<ContentTypeBuilderNav />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -98,7 +87,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders and matches the snapshot', () => {
|
it('renders and matches the snapshot', () => {
|
||||||
const App = makeApp();
|
|
||||||
const { container } = render(App);
|
const { container } = render(App);
|
||||||
|
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@ -106,7 +94,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
describe('save button', () => {
|
describe('save button', () => {
|
||||||
it('should render the save button', () => {
|
it('should render the save button', () => {
|
||||||
const App = makeApp();
|
|
||||||
const { getByRole } = render(App);
|
const { getByRole } = render(App);
|
||||||
|
|
||||||
const saveButton = getByRole('button', { name: /save/i });
|
const saveButton = getByRole('button', { name: /save/i });
|
||||||
@ -114,8 +101,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should be disabled when there are no changes', () => {
|
it('should be disabled when there are no changes', () => {
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementationOnce(
|
mockedUseDataManager.mockImplementationOnce(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@ -137,8 +122,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
it.each([true, false])(
|
it.each([true, false])(
|
||||||
'should be disabled when not in development mode & isModified=%s',
|
'should be disabled when not in development mode & isModified=%s',
|
||||||
(isModified) => {
|
(isModified) => {
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@ -159,8 +142,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it('should be enabled when there are changes', () => {
|
it('should be enabled when there are changes', () => {
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@ -183,7 +164,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
describe('unde redo discardAllChanges', () => {
|
describe('unde redo discardAllChanges', () => {
|
||||||
it('should render the undo item', async () => {
|
it('should render the undo item', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -194,7 +174,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should render the redo item', async () => {
|
it('should render the redo item', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -205,7 +185,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should render the discard item', async () => {
|
it('should render the discard item', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
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 () => {
|
it('should render the undo item as disabled if not in development mode', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -241,7 +220,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should render the redo item as disabled if not in development mode', async () => {
|
it('should render the redo item as disabled if not in development mode', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -266,7 +244,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should render the discard item as disabled if not in development mode', async () => {
|
it('should render the discard item as disabled if not in development mode', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -298,7 +275,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
},
|
},
|
||||||
])('should enable the undo item when there are changes to undo', async (opts) => {
|
])('should enable the undo item when there are changes to undo', async (opts) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -331,7 +307,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
},
|
},
|
||||||
])('should enable the redo item when there are changes to redo', async (opts) => {
|
])('should enable the redo item when there are changes to redo', async (opts) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -364,7 +339,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
},
|
},
|
||||||
])('should enable the discard item when there are changes to discard', async (opts) => {
|
])('should enable the discard item when there are changes to discard', async (opts) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
|
|
||||||
mockedUseDataManager.mockImplementation(
|
mockedUseDataManager.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -390,7 +364,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should open the discard confirmation modal', async () => {
|
it('should open the discard confirmation modal', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
const { getByRole } = render(App);
|
const { getByRole } = render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -406,7 +380,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should close the discard confirmation modal', async () => {
|
it('should close the discard confirmation modal', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -427,7 +401,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should call discardChanges after confirm', async () => {
|
it('should call discardChanges after confirm', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -444,7 +418,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should call undoHandler', async () => {
|
it('should call undoHandler', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -459,7 +433,7 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
it('should call redoHandler', async () => {
|
it('should call redoHandler', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
const moreActionsButton = screen.getByRole('button', { name: /More actions/i });
|
||||||
@ -476,7 +450,6 @@ describe('<ContentTypeBuilderNav />', () => {
|
|||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
it('should render the search input', () => {
|
it('should render the search input', () => {
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument();
|
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 () => {
|
it('Should call search.onChange when the input value changes', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const input = screen.getByRole('textbox', { name: /search/i });
|
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 () => {
|
it('Should clear the search input when the clear button is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const App = makeApp();
|
|
||||||
render(App);
|
render(App);
|
||||||
|
|
||||||
const input = screen.getByRole('textbox', { name: /search/i });
|
const input = screen.getByRole('textbox', { name: /search/i });
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
useAppInfo,
|
useAppInfo,
|
||||||
useFetchClient,
|
useFetchClient,
|
||||||
useAuth,
|
useAuth,
|
||||||
|
adminApi,
|
||||||
} from '@strapi/admin/strapi-admin';
|
} from '@strapi/admin/strapi-admin';
|
||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
@ -181,7 +182,9 @@ const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
|
|||||||
|
|
||||||
// Make sure the server has restarted
|
// Make sure the server has restarted
|
||||||
await serverRestartWatcher();
|
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
|
// refetch and update initial state after the data has been saved
|
||||||
await getDataRef.current();
|
await getDataRef.current();
|
||||||
// Update the app's permissions
|
// Update the app's permissions
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useStrapiApp, useTracking, useNotification } from '@strapi/admin/strapi-admin';
|
import {
|
||||||
import { Button, Divider, Flex, Modal, Tabs } from '@strapi/design-system';
|
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 get from 'lodash/get';
|
||||||
import has from 'lodash/has';
|
import has from 'lodash/has';
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
@ -44,6 +50,7 @@ import { SingularName } from '../SingularName';
|
|||||||
import { TabForm } from '../TabForm';
|
import { TabForm } from '../TabForm';
|
||||||
import { TextareaEnum } from '../TextareaEnum';
|
import { TextareaEnum } from '../TextareaEnum';
|
||||||
|
|
||||||
|
import { ConditionForm } from './attributes/ConditionForm';
|
||||||
import { forms } from './forms/forms';
|
import { forms } from './forms/forms';
|
||||||
import { actions, initialState, type State as FormModalState } from './reducer';
|
import { actions, initialState, type State as FormModalState } from './reducer';
|
||||||
import { canEditContentType } from './utils/canEditContentType';
|
import { canEditContentType } from './utils/canEditContentType';
|
||||||
@ -129,6 +136,54 @@ export const FormModal = () => {
|
|||||||
|
|
||||||
const type = forTarget === 'component' ? components[targetUid] : contentTypes[targetUid];
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const collectionTypesForRelation = sortedContentTypesList.filter(
|
const collectionTypesForRelation = sortedContentTypesList.filter(
|
||||||
@ -400,7 +455,7 @@ export const FormModal = () => {
|
|||||||
({
|
({
|
||||||
target: { name, value, type, ...rest },
|
target: { name, value, type, ...rest },
|
||||||
}: {
|
}: {
|
||||||
target: { name: string; value: string; type: string };
|
target: { name: string; value: string | string[]; type: string };
|
||||||
}) => {
|
}) => {
|
||||||
const namesThatCanResetToNullValue = [
|
const namesThatCanResetToNullValue = [
|
||||||
'enumName',
|
'enumName',
|
||||||
@ -416,6 +471,9 @@ export const FormModal = () => {
|
|||||||
|
|
||||||
if (namesThatCanResetToNullValue.includes(name) && value === '') {
|
if (namesThatCanResetToNullValue.includes(name) && value === '') {
|
||||||
val = null;
|
val = null;
|
||||||
|
} else if (name === 'enum') {
|
||||||
|
// For enum values, ensure we're working with an array
|
||||||
|
val = Array.isArray(value) ? value : [value];
|
||||||
} else {
|
} else {
|
||||||
val = value;
|
val = value;
|
||||||
}
|
}
|
||||||
@ -451,9 +509,7 @@ export const FormModal = () => {
|
|||||||
[dispatch, formErrors]
|
[dispatch, formErrors]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SyntheticEvent, shouldContinue = isCreating) => {
|
const submitForm = async (e: React.SyntheticEvent, shouldContinue = isCreating) => {
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkFormValidity();
|
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 = () => {
|
const handleConfirmClose = () => {
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
const confirm = window.confirm(
|
const confirm = window.confirm(
|
||||||
@ -929,6 +999,7 @@ export const FormModal = () => {
|
|||||||
'text-plural': PluralName,
|
'text-plural': PluralName,
|
||||||
'text-singular': SingularName,
|
'text-singular': SingularName,
|
||||||
'textarea-enum': TextareaEnum,
|
'textarea-enum': TextareaEnum,
|
||||||
|
'condition-form': ConditionForm,
|
||||||
...inputsFromPlugins,
|
...inputsFromPlugins,
|
||||||
},
|
},
|
||||||
componentToCreate,
|
componentToCreate,
|
||||||
@ -942,6 +1013,7 @@ export const FormModal = () => {
|
|||||||
isCreating,
|
isCreating,
|
||||||
targetUid,
|
targetUid,
|
||||||
forTarget,
|
forTarget,
|
||||||
|
contentTypeSchema: type,
|
||||||
};
|
};
|
||||||
|
|
||||||
const advancedForm = formToDisplay.advanced({
|
const advancedForm = formToDisplay.advanced({
|
||||||
@ -992,6 +1064,79 @@ export const FormModal = () => {
|
|||||||
return (
|
return (
|
||||||
<Modal.Root open={isOpen} onOpenChange={handleClosed}>
|
<Modal.Root open={isOpen} onOpenChange={handleClosed}>
|
||||||
<Modal.Content>
|
<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
|
<FormModalHeader
|
||||||
actionType={actionType}
|
actionType={actionType}
|
||||||
attributeName={attributeName}
|
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';
|
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 = {
|
export const advancedForm = {
|
||||||
blocks() {
|
blocks() {
|
||||||
return {
|
return {
|
||||||
@ -16,6 +41,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -60,6 +86,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -97,6 +124,7 @@ export const advancedForm = {
|
|||||||
maxComponentsAttribute,
|
maxComponentsAttribute,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -110,6 +138,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -136,6 +165,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.unique, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.unique, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -149,6 +179,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.max, attributeOptions.min],
|
items: [attributeOptions.required, attributeOptions.max, attributeOptions.min],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -164,7 +195,6 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
id: 'global.settings',
|
id: 'global.settings',
|
||||||
@ -178,6 +208,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.private,
|
attributeOptions.private,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -234,7 +265,6 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
id: 'global.settings',
|
id: 'global.settings',
|
||||||
@ -242,6 +272,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -255,6 +286,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -284,6 +316,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.required, attributeOptions.private],
|
items: [attributeOptions.required, attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -321,6 +354,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.private,
|
attributeOptions.private,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -328,7 +362,6 @@ export const advancedForm = {
|
|||||||
return {
|
return {
|
||||||
sections: [
|
sections: [
|
||||||
{ sectionTitle: null, items: [attributeOptions.default] },
|
{ sectionTitle: null, items: [attributeOptions.default] },
|
||||||
|
|
||||||
{
|
{
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
id: 'global.settings',
|
id: 'global.settings',
|
||||||
@ -341,6 +374,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.private,
|
attributeOptions.private,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -354,6 +388,7 @@ export const advancedForm = {
|
|||||||
},
|
},
|
||||||
items: [attributeOptions.private],
|
items: [attributeOptions.private],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -373,6 +408,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.private,
|
attributeOptions.private,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -380,7 +416,6 @@ export const advancedForm = {
|
|||||||
return {
|
return {
|
||||||
sections: [
|
sections: [
|
||||||
{ sectionTitle: null, items: [attributeOptions.default, attributeOptions.regex] },
|
{ sectionTitle: null, items: [attributeOptions.default, attributeOptions.regex] },
|
||||||
|
|
||||||
{
|
{
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
id: 'global.settings',
|
id: 'global.settings',
|
||||||
@ -394,6 +429,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.private,
|
attributeOptions.private,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -406,7 +442,6 @@ export const advancedForm = {
|
|||||||
{ ...attributeOptions.default, disabled: Boolean(data.targetField), type: 'text' },
|
{ ...attributeOptions.default, disabled: Boolean(data.targetField), type: 'text' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
id: 'global.settings',
|
id: 'global.settings',
|
||||||
@ -420,6 +455,7 @@ export const advancedForm = {
|
|||||||
attributeOptions.regex,
|
attributeOptions.regex,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
conditionSection,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -162,11 +162,14 @@ export const forms = {
|
|||||||
...rest,
|
...rest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let injected = false;
|
||||||
|
|
||||||
const sections = baseForm.reduce((acc: Array<any>, current: any) => {
|
const sections = baseForm.reduce((acc: Array<any>, current: any) => {
|
||||||
if (current.sectionTitle === null) {
|
if (current.sectionTitle === null || injected) {
|
||||||
acc.push(current);
|
acc.push(current);
|
||||||
} else {
|
} else {
|
||||||
acc.push({ ...current, items: [...current.items, ...itemsToAdd] });
|
acc.push({ ...current, items: [...current.items, ...itemsToAdd] });
|
||||||
|
injected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -44,10 +44,38 @@ interface InputOption {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomInputProps<TAttribute extends Schema.Attribute.AnyAttribute>
|
interface CustomInputProps<
|
||||||
extends Omit<GenericInputProps<TAttribute>, 'customInputs'> {
|
TAttribute extends Schema.Attribute.AnyAttribute = Schema.Attribute.AnyAttribute,
|
||||||
ref?: React.Ref<HTMLElement>;
|
> {
|
||||||
|
attribute?: TAttribute;
|
||||||
|
autoComplete?: string;
|
||||||
|
description?: TranslationMessage;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
hint?: string | React.JSX.Element | (string | React.JSX.Element)[];
|
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<
|
interface GenericInputProps<
|
||||||
@ -72,6 +100,7 @@ interface GenericInputProps<
|
|||||||
},
|
},
|
||||||
shouldSetInitialValue?: boolean
|
shouldSetInitialValue?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
options?: InputOption[];
|
options?: InputOption[];
|
||||||
placeholder?: TranslationMessage;
|
placeholder?: TranslationMessage;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@ -81,6 +110,8 @@ interface GenericInputProps<
|
|||||||
value?: Schema.Attribute.Value<TAttribute>;
|
value?: Schema.Attribute.Value<TAttribute>;
|
||||||
isNullable?: boolean;
|
isNullable?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
attributeName?: string;
|
||||||
|
conditionFields?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenericInput = ({
|
const GenericInput = ({
|
||||||
@ -93,6 +124,7 @@ const GenericInput = ({
|
|||||||
error,
|
error,
|
||||||
name,
|
name,
|
||||||
onChange,
|
onChange,
|
||||||
|
onDelete,
|
||||||
options = [],
|
options = [],
|
||||||
placeholder,
|
placeholder,
|
||||||
required,
|
required,
|
||||||
@ -102,6 +134,8 @@ const GenericInput = ({
|
|||||||
isNullable,
|
isNullable,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
attribute,
|
attribute,
|
||||||
|
attributeName,
|
||||||
|
conditionFields,
|
||||||
...rest
|
...rest
|
||||||
}: GenericInputProps) => {
|
}: GenericInputProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
@ -195,12 +229,15 @@ const GenericInput = ({
|
|||||||
error={errorMessage || ''}
|
error={errorMessage || ''}
|
||||||
name={name}
|
name={name}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onDelete={onDelete}
|
||||||
options={options}
|
options={options}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
autoFocus={autoFocus}
|
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 get from 'lodash/get';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { formatCondition, getAvailableConditionFields } from '../utils/conditions';
|
||||||
|
|
||||||
import { GenericInput } from './GenericInputs';
|
import { GenericInput } from './GenericInputs';
|
||||||
|
|
||||||
interface TabFormProps {
|
interface TabFormProps {
|
||||||
@ -29,7 +31,6 @@ export const TabForm = ({
|
|||||||
if (section.items.length === 0) {
|
if (section.items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={sectionIndex}>
|
<Box key={sectionIndex}>
|
||||||
{section.sectionTitle && (
|
{section.sectionTitle && (
|
||||||
@ -39,6 +40,12 @@ export const TabForm = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{section.intlLabel && (
|
||||||
|
<Typography variant="pi" textColor="neutral600">
|
||||||
|
{formatMessage(section.intlLabel)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid.Root gap={4}>
|
<Grid.Root gap={4}>
|
||||||
{section.items.map((input: any, i: number) => {
|
{section.items.map((input: any, i: number) => {
|
||||||
const key = `${sectionIndex}.${i}`;
|
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 (
|
return (
|
||||||
<Grid.Item
|
<Grid.Item
|
||||||
col={input.size || 6}
|
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 { useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { getTrad } from '../../utils/getTrad';
|
import { getTrad } from '../../utils/getTrad';
|
||||||
@ -13,6 +14,10 @@ export const EmptyState = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<unstable_tours.contentTypeBuilder.Introduction>
|
||||||
|
{/* Invisible Anchor */}
|
||||||
|
<Box paddingTop={5} />
|
||||||
|
</unstable_tours.contentTypeBuilder.Introduction>
|
||||||
<Flex justifyContent="center" alignItems="center" height="100%" direction="column">
|
<Flex justifyContent="center" alignItems="center" height="100%" direction="column">
|
||||||
<Typography variant="alpha">{pluginName}</Typography>
|
<Typography variant="alpha">{pluginName}</Typography>
|
||||||
<Typography variant="delta">
|
<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.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": "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.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-components-to-dynamiczone": "Add components to the zone",
|
||||||
"form.button.add-field": "Add another field",
|
"form.button.add-field": "Add another field",
|
||||||
"form.button.add-first-field-to-created-component": "Add first field to the component",
|
"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.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.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.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.",
|
"prompt.unsaved": "Are you sure you want to leave? All your modifications will be lost.",
|
||||||
"relation.attributeName.placeholder": "Ex: author, category, tag",
|
"relation.attributeName.placeholder": "Ex: author, category, tag",
|
||||||
"relation.manyToMany": "has and belongs to many",
|
"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",
|
"name": "@strapi/content-type-builder",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Create and manage content types",
|
"description": "Create and manage content types",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -66,10 +66,10 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@reduxjs/toolkit": "1.9.7",
|
"@reduxjs/toolkit": "1.9.7",
|
||||||
"@sindresorhus/slugify": "1.1.0",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
"@strapi/design-system": "2.0.0-rc.27",
|
"@strapi/design-system": "2.0.0-rc.28",
|
||||||
"@strapi/generators": "5.16.1",
|
"@strapi/generators": "5.17.0",
|
||||||
"@strapi/icons": "2.0.0-rc.27",
|
"@strapi/icons": "2.0.0-rc.28",
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"fs-extra": "11.2.0",
|
"fs-extra": "11.2.0",
|
||||||
"immer": "9.0.21",
|
"immer": "9.0.21",
|
||||||
@ -82,8 +82,8 @@
|
|||||||
"zod": "3.24.2"
|
"zod": "3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@strapi/admin": "5.16.1",
|
"@strapi/admin": "5.17.0",
|
||||||
"@strapi/types": "5.16.1",
|
"@strapi/types": "5.17.0",
|
||||||
"@testing-library/dom": "10.1.0",
|
"@testing-library/dom": "10.1.0",
|
||||||
"@testing-library/react": "15.0.7",
|
"@testing-library/react": "15.0.7",
|
||||||
"@testing-library/user-event": "14.5.2",
|
"@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({
|
const basePropertiesSchema = z.object({
|
||||||
type: z.enum([
|
type: z.enum([
|
||||||
'string',
|
'string',
|
||||||
@ -204,6 +208,9 @@ const basePropertiesSchema = z.object({
|
|||||||
configurable: z.boolean().nullish(),
|
configurable: z.boolean().nullish(),
|
||||||
private: z.boolean().nullish(),
|
private: z.boolean().nullish(),
|
||||||
pluginOptions: z.record(z.unknown()).optional(),
|
pluginOptions: z.record(z.unknown()).optional(),
|
||||||
|
conditions: z.preprocess((val) => {
|
||||||
|
return val;
|
||||||
|
}, conditionSchema.optional()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxLengthSchema = z.number().int().positive().optional();
|
const maxLengthSchema = z.number().int().positive().optional();
|
||||||
@ -229,6 +236,9 @@ const baseRelationSchema = z.object({
|
|||||||
configurable: z.boolean().nullish(),
|
configurable: z.boolean().nullish(),
|
||||||
private: z.boolean().nullish(),
|
private: z.boolean().nullish(),
|
||||||
pluginOptions: z.record(z.unknown()).optional(),
|
pluginOptions: z.record(z.unknown()).optional(),
|
||||||
|
conditions: z.preprocess((val) => {
|
||||||
|
return val;
|
||||||
|
}, conditionSchema.optional()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const oneToOneSchema = baseRelationSchema.extend({
|
const oneToOneSchema = baseRelationSchema.extend({
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@strapi/core",
|
"name": "@strapi/core",
|
||||||
"version": "5.16.1",
|
"version": "5.17.0",
|
||||||
"description": "Core of Strapi",
|
"description": "Core of Strapi",
|
||||||
"homepage": "https://strapi.io",
|
"homepage": "https://strapi.io",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
@ -56,14 +56,14 @@
|
|||||||
"@koa/cors": "5.0.0",
|
"@koa/cors": "5.0.0",
|
||||||
"@koa/router": "12.0.2",
|
"@koa/router": "12.0.2",
|
||||||
"@paralleldrive/cuid2": "2.2.2",
|
"@paralleldrive/cuid2": "2.2.2",
|
||||||
"@strapi/admin": "5.16.1",
|
"@strapi/admin": "5.17.0",
|
||||||
"@strapi/database": "5.16.1",
|
"@strapi/database": "5.17.0",
|
||||||
"@strapi/generators": "5.16.1",
|
"@strapi/generators": "5.17.0",
|
||||||
"@strapi/logger": "5.16.1",
|
"@strapi/logger": "5.17.0",
|
||||||
"@strapi/permissions": "5.16.1",
|
"@strapi/permissions": "5.17.0",
|
||||||
"@strapi/types": "5.16.1",
|
"@strapi/types": "5.17.0",
|
||||||
"@strapi/typescript-utils": "5.16.1",
|
"@strapi/typescript-utils": "5.17.0",
|
||||||
"@strapi/utils": "5.16.1",
|
"@strapi/utils": "5.17.0",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"boxen": "5.1.2",
|
"boxen": "5.1.2",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
@ -82,6 +82,7 @@
|
|||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"inquirer": "8.2.5",
|
"inquirer": "8.2.5",
|
||||||
"is-docker": "2.2.1",
|
"is-docker": "2.2.1",
|
||||||
|
"json-logic-js": "2.0.5",
|
||||||
"koa": "2.16.1",
|
"koa": "2.16.1",
|
||||||
"koa-body": "6.0.1",
|
"koa-body": "6.0.1",
|
||||||
"koa-compose": "4.1.0",
|
"koa-compose": "4.1.0",
|
||||||
@ -116,6 +117,7 @@
|
|||||||
"@types/global-agent": "2.1.3",
|
"@types/global-agent": "2.1.3",
|
||||||
"@types/http-errors": "2.0.4",
|
"@types/http-errors": "2.0.4",
|
||||||
"@types/jest": "29.5.2",
|
"@types/jest": "29.5.2",
|
||||||
|
"@types/json-logic-js": "2.0.8",
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/koa-compress": "4.0.3",
|
"@types/koa-compress": "4.0.3",
|
||||||
"@types/koa-session": "6.4.1",
|
"@types/koa-session": "6.4.1",
|
||||||
@ -126,9 +128,9 @@
|
|||||||
"@types/node": "18.19.24",
|
"@types/node": "18.19.24",
|
||||||
"@types/node-schedule": "2.1.7",
|
"@types/node-schedule": "2.1.7",
|
||||||
"@types/statuses": "2.0.1",
|
"@types/statuses": "2.0.1",
|
||||||
"eslint-config-custom": "5.16.1",
|
"eslint-config-custom": "5.17.0",
|
||||||
"supertest": "6.3.3",
|
"supertest": "6.3.3",
|
||||||
"tsconfig": "5.16.1"
|
"tsconfig": "5.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0 <=22.x.x",
|
"node": ">=18.0.0 <=22.x.x",
|
||||||
|
@ -29,6 +29,7 @@ import createAuth from './services/auth';
|
|||||||
import createCustomFields from './services/custom-fields';
|
import createCustomFields from './services/custom-fields';
|
||||||
import createContentAPI from './services/content-api';
|
import createContentAPI from './services/content-api';
|
||||||
import getNumberOfDynamicZones from './services/utils/dynamic-zones';
|
import getNumberOfDynamicZones from './services/utils/dynamic-zones';
|
||||||
|
import getNumberOfConditionalFields from './services/utils/conditional-fields';
|
||||||
import { FeaturesService, createFeaturesService } from './services/features';
|
import { FeaturesService, createFeaturesService } from './services/features';
|
||||||
import { createDocumentService } from './services/document-service';
|
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.
|
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),
|
numberOfComponents: _.size(this.components),
|
||||||
numberOfDynamicZones: getNumberOfDynamicZones(),
|
numberOfDynamicZones: getNumberOfDynamicZones(),
|
||||||
|
numberOfConditionalFields: getNumberOfConditionalFields(),
|
||||||
numberOfCustomControllers: Object.values<Core.Controller>(this.controllers).filter(
|
numberOfCustomControllers: Object.values<Core.Controller>(this.controllers).filter(
|
||||||
// TODO: Fix this at the content API loader level to prevent future types issues
|
// TODO: Fix this at the content API loader level to prevent future types issues
|
||||||
(controller) => controller !== undefined && factories.isCustomController(controller)
|
(controller) => controller !== undefined && factories.isCustomController(controller)
|
||||||
|
@ -18,7 +18,6 @@ const createEntriesService = (
|
|||||||
|
|
||||||
async function createEntry(params = {} as any) {
|
async function createEntry(params = {} as any) {
|
||||||
const { data, ...restParams } = await transformParamsDocumentId(uid, params);
|
const { data, ...restParams } = await transformParamsDocumentId(uid, params);
|
||||||
|
|
||||||
const query = transformParamsToQuery(uid, pickSelectionParams(restParams) as any); // select / populate
|
const query = transformParamsToQuery(uid, pickSelectionParams(restParams) as any); // select / populate
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
@ -92,6 +92,40 @@ const localeToData: Transform = (contentType, params) => {
|
|||||||
return 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 defaultLocaleCurry = curry(defaultLocale);
|
||||||
const localeToLookupCurry = curry(localeToLookup);
|
const localeToLookupCurry = curry(localeToLookup);
|
||||||
const multiLocaleToLookupCurry = curry(multiLocaleToLookup);
|
const multiLocaleToLookupCurry = curry(multiLocaleToLookup);
|
||||||
@ -102,4 +136,5 @@ export {
|
|||||||
localeToLookupCurry as localeToLookup,
|
localeToLookupCurry as localeToLookup,
|
||||||
localeToDataCurry as localeToData,
|
localeToDataCurry as localeToData,
|
||||||
multiLocaleToLookupCurry as multiLocaleToLookup,
|
multiLocaleToLookupCurry as multiLocaleToLookup,
|
||||||
|
copyNonLocalizedFields,
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@ import type { UID } from '@strapi/types';
|
|||||||
import { wrapInTransaction, type RepositoryFactoryMethod } from './common';
|
import { wrapInTransaction, type RepositoryFactoryMethod } from './common';
|
||||||
import * as DP from './draft-and-publish';
|
import * as DP from './draft-and-publish';
|
||||||
import * as i18n from './internationalization';
|
import * as i18n from './internationalization';
|
||||||
|
import { copyNonLocalizedFields } from './internationalization';
|
||||||
import * as components from './components';
|
import * as components from './components';
|
||||||
|
|
||||||
import { createEntriesService } from './entries';
|
import { createEntriesService } from './entries';
|
||||||
@ -240,9 +241,14 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (
|
|||||||
.findOne({ where: { documentId } });
|
.findOne({ where: { documentId } });
|
||||||
|
|
||||||
if (documentExists) {
|
if (documentExists) {
|
||||||
|
const mergedData = await copyNonLocalizedFields(contentType, documentId, {
|
||||||
|
...queryParams.data,
|
||||||
|
documentId,
|
||||||
|
});
|
||||||
|
|
||||||
updatedDraft = await entries.create({
|
updatedDraft = await entries.create({
|
||||||
...queryParams,
|
...queryParams,
|
||||||
data: { ...queryParams.data, documentId },
|
data: mergedData,
|
||||||
});
|
});
|
||||||
emitEvent('entry.create', updatedDraft);
|
emitEvent('entry.create', updatedDraft);
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { uniqBy, castArray, isNil, isArray, mergeWith } from 'lodash';
|
import { uniqBy, castArray, isNil, isArray, mergeWith } from 'lodash';
|
||||||
import { has, prop, isObject, isEmpty } from 'lodash/fp';
|
import { has, prop, isObject, isEmpty } from 'lodash/fp';
|
||||||
|
import jsonLogic from 'json-logic-js';
|
||||||
import strapiUtils from '@strapi/utils';
|
import strapiUtils from '@strapi/utils';
|
||||||
import type { Modules, UID, Struct, Schema } from '@strapi/types';
|
import type { Modules, UID, Struct, Schema } from '@strapi/types';
|
||||||
import { Validators, ValidatorMetas } from './validators';
|
import { Validators, ValidatorMetas } from './validators';
|
||||||
@ -268,6 +269,15 @@ const createAttributeValidator =
|
|||||||
(createOrUpdate: CreateOrUpdate) => (metas: ValidatorMetas, options: ValidatorContext) => {
|
(createOrUpdate: CreateOrUpdate) => (metas: ValidatorMetas, options: ValidatorContext) => {
|
||||||
let validator = yup.mixed();
|
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)) {
|
if (isMediaAttribute(metas.attr)) {
|
||||||
validator = yup.mixed();
|
validator = yup.mixed();
|
||||||
} else if (isScalarAttribute(metas.attr)) {
|
} else if (isScalarAttribute(metas.attr)) {
|
||||||
@ -342,6 +352,7 @@ const createModelValidator =
|
|||||||
const metas = {
|
const metas = {
|
||||||
attr: model.attributes[attributeName],
|
attr: model.attributes[attributeName],
|
||||||
updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
|
updatedAttribute: { name: attributeName, value: prop(attributeName, data) },
|
||||||
|
data,
|
||||||
model,
|
model,
|
||||||
entity,
|
entity,
|
||||||
componentContext,
|
componentContext,
|
||||||
|
@ -25,6 +25,7 @@ export interface ValidatorMetas<
|
|||||||
name: string;
|
name: string;
|
||||||
value: TValue;
|
value: TValue;
|
||||||
};
|
};
|
||||||
|
data: Record<string, unknown>;
|
||||||
componentContext?: ComponentContext;
|
componentContext?: ComponentContext;
|
||||||
entity?: Modules.EntityValidator.Entity;
|
entity?: Modules.EntityValidator.Entity;
|
||||||
}
|
}
|
||||||
|
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