From 9c7149630dabd4c43f44cf68ed4ce6d7562607e8 Mon Sep 17 00:00:00 2001 From: markkaylor Date: Fri, 4 Jul 2025 16:47:39 +0200 Subject: [PATCH 1/4] future: add guided tour overview (#23868) --- .../components/UnstableGuidedTour/Context.tsx | 62 ++- .../UnstableGuidedTour/Overview.tsx | 252 ++++++++++ .../components/UnstableGuidedTour/Step.tsx | 10 +- .../components/UnstableGuidedTour/Tours.tsx | 124 ++--- .../UnstableGuidedTour/tests/reducer.test.ts | 470 +++++++++++++++++- .../admin/admin/src/pages/Home/HomePage.tsx | 7 +- .../core/admin/admin/src/translations/en.json | 15 +- 7 files changed, 824 insertions(+), 116 deletions(-) create mode 100644 packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx index d6e9d6f6ee..1d4a52e533 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { produce } from 'immer'; import { GetGuidedTourMeta } from '../../../../shared/contracts/admin'; -import { useGetGuidedTourMetaQuery } from '../../services/admin'; +import { usePersistentState } from '../../hooks/usePersistentState'; import { createContext } from '../Context'; import { type Tours, tours as guidedTours } from './Tours'; @@ -31,6 +31,9 @@ type Action = | { type: 'set_completed_actions'; payload: ExtendedCompletedActions; + } + | { + type: 'skip_all_tours'; }; type Tour = Record; @@ -60,9 +63,15 @@ function reducer(state: State, action: Action): State { 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 = ({ children, enabled = true, @@ -70,31 +79,30 @@ const UnstableGuidedTourContext = ({ children: React.ReactNode; enabled?: boolean; }) => { - const stored = getTourStateFromLocalStorage(); - const initialState = stored - ? stored - : { - tours: Object.keys(guidedTours).reduce((acc, tourName) => { - const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length; - acc[tourName as ValidTourName] = { - currentStep: 0, - length: tourLength, - isCompleted: false, - }; - return acc; - }, {} as Tour), - enabled, - completedActions: [], - }; + const initialTourState = Object.keys(guidedTours).reduce((acc, tourName) => { + const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length; + acc[tourName as ValidTourName] = { + currentStep: 0, + length: tourLength, + isCompleted: false, + }; - const [state, dispatch] = React.useReducer(reducer, initialState); + return acc; + }, {} as Tour); + + const [tours, setTours] = usePersistentState(STORAGE_KEY, { + tours: initialTourState, + enabled, + completedActions: [], + }); + const [state, dispatch] = React.useReducer(reducer, tours); // Sync local storage React.useEffect(() => { if (window.strapi.future.isEnabled('unstableGuidedTour')) { - saveTourStateToLocalStorage(state); + setTours(state); } - }, [state]); + }, [state, setTours]); return ( @@ -103,19 +111,5 @@ const UnstableGuidedTourContext = ({ ); }; -/* ------------------------------------------------------------------------------------------------- - * Local Storage - * -----------------------------------------------------------------------------------------------*/ - -const STORAGE_KEY = 'STRAPI_GUIDED_TOUR'; -function getTourStateFromLocalStorage(): State | null { - const tourState = localStorage.getItem(STORAGE_KEY); - return tourState ? JSON.parse(tourState) : null; -} - -function saveTourStateToLocalStorage(state: State) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); -} - export type { Action, State, ValidTourName }; export { UnstableGuidedTourContext, unstableUseGuidedTour, reducer }; diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx new file mode 100644 index 0000000000..78bf28dc88 --- /dev/null +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx @@ -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 ( + + + + ); +}; + +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 ( + + {/* Greeting */} + + + + + {formatMessage({ + id: 'tours.overview.title', + defaultMessage: 'Discover your application!', + })} + + + {formatMessage({ + id: 'tours.overview.subtitle', + defaultMessage: 'Follow the guided tour to get the most out of Strapi.', + })} + + + + {completionPercentage}% + + + + + + {/* Task List */} + + + {formatMessage({ + id: 'tours.overview.tasks', + defaultMessage: 'Your tasks', + })} + + + {TASK_CONTENT.map((task) => { + const tour = tours[task.tourName as ValidTourName]; + + return ( + + {tour.isCompleted ? ( + <> + + + + {formatMessage(task.title)} + + + + {formatMessage(task.done)} + + + ) : ( + <> + + + + + {formatMessage(task.title)} + + {task.isExternal ? ( + + dispatch({ type: 'skip_tour', payload: task.tourName as ValidTourName }) + } + > + {formatMessage(task.link.label)} + + ) : ( + } to={task.link.to} tag={NavLink}> + {formatMessage(task.link.label)} + + )} + + )} + + ); + })} + + + + ); +}; diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Step.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Step.tsx index 53705bdb6a..08c448cfeb 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/Step.tsx +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Step.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Popover, Box, Flex, Button, Typography, LinkButton } from '@strapi/design-system'; import { FormattedMessage, type MessageDescriptor } from 'react-intl'; -import { useNavigate } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { styled } from 'styled-components'; import { unstableUseGuidedTour, ValidTourName } from './Context'; @@ -85,7 +85,6 @@ const createStepComponents = (tourName: ValidTourName): Step => ({ ), Actions: ({ showStepCount = true, showSkip = false, to, ...props }) => { - const navigate = useNavigate(); const dispatch = unstableUseGuidedTour('GuidedTourPopover', (s) => s.dispatch); const state = unstableUseGuidedTour('GuidedTourPopover', (s) => s.state); const currentStep = state.tours[tourName].currentStep + 1; @@ -118,10 +117,9 @@ const createStepComponents = (tourName: ValidTourName): Step => ({ )} {to ? ( { - dispatch({ type: 'next_step', payload: tourName }); - navigate(to); - }} + tag={NavLink} + to={to} + onClick={() => dispatch({ type: 'next_step', payload: tourName })} > diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx index cf6a93b98b..f5b7f35e91 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx @@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl'; import { NavLink } from 'react-router-dom'; import { styled } from 'styled-components'; -import { type GetGuidedTourMeta } from '../../../../shared/contracts/admin'; import { useGetGuidedTourMetaQuery } from '../../services/admin'; import { @@ -102,6 +101,67 @@ const tours = { when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'), }, ]), + contentManager: createTour('contentManager', [ + { + name: 'Introduction', + content: (Step) => ( + + + + + + ), + }, + { + name: 'Fields', + content: (Step) => ( + + + + + + ), + }, + { + name: 'Publish', + content: (Step) => ( + + + + + + ), + }, + { + name: 'Finish', + content: (Step) => ( + + + + + + ), + when: (completedActions) => completedActions.includes('didCreateContent'), + }, + ]), apiTokens: createTour('apiTokens', [ { name: 'Introduction', @@ -183,67 +243,7 @@ const tours = { when: (completedActions) => completedActions.includes('didCopyApiToken'), }, ]), - contentManager: createTour('contentManager', [ - { - name: 'Introduction', - content: (Step) => ( - - - - - - ), - }, - { - name: 'Fields', - content: (Step) => ( - - - - - - ), - }, - { - name: 'Publish', - content: (Step) => ( - - - - - - ), - }, - { - name: 'Finish', - content: (Step) => ( - - - - - - ), - when: (completedActions) => completedActions.includes('didCreateContent'), - }, - ]), + strapiCloud: createTour('strapiCloud', []), } as const; type Tours = typeof tours; diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts index fd5c6a6d7e..1601c14197 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts @@ -1,4 +1,4 @@ -import { type Action, reducer } from '../Context'; +import { type Action, type ExtendedCompletedActions, reducer } from '../Context'; describe('GuidedTour | reducer', () => { describe('next_step', () => { @@ -20,9 +20,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; const action: Action = { @@ -47,9 +52,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -73,9 +83,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; const action: Action = { @@ -100,9 +115,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -126,9 +146,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; const action: Action = { @@ -153,9 +178,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -181,9 +211,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; const action: Action = { @@ -208,9 +243,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -234,9 +274,14 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + completedActions: [] as ExtendedCompletedActions, }; const action: Action = { @@ -261,9 +306,412 @@ describe('GuidedTour | reducer', () => { isCompleted: false, length: 3, }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + length: 0, + }, }, enabled: true, - completedActions: [], + 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: { + 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: 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); diff --git a/packages/core/admin/admin/src/pages/Home/HomePage.tsx b/packages/core/admin/admin/src/pages/Home/HomePage.tsx index 209a13de47..8695956120 100644 --- a/packages/core/admin/admin/src/pages/Home/HomePage.tsx +++ b/packages/core/admin/admin/src/pages/Home/HomePage.tsx @@ -7,6 +7,7 @@ import { Link as ReactRouterLink } from 'react-router-dom'; import { Layouts } from '../../components/Layouts/Layout'; import { Page } from '../../components/PageHelpers'; +import { UnstableGuidedTourOverview } from '../../components/UnstableGuidedTour/Overview'; import { Widget } from '../../components/WidgetHelpers'; import { useEnterprise } from '../../ee'; import { useAuth } from '../../features/Auth'; @@ -153,7 +154,11 @@ const HomePageCE = () => { - + {window.strapi.future.isEnabled('unstableGuidedTour') ? ( + + ) : ( + + )} {getAllWidgets().map((widget) => { return ( diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index a27db14693..a4a1cd55ef 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -819,6 +819,17 @@ "tours.stepCount": "Step {currentStep} of {tourLength}", "tours.skip": "Skip", "tours.next": "Next", - "widget.profile.title": "Profile", - "tours.gotIt": "Got it" + "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" } From 725a3c5254a572e79c253dc378340219d47a1fd5 Mon Sep 17 00:00:00 2001 From: Bassel Kanso Date: Mon, 7 Jul 2025 09:50:18 +0300 Subject: [PATCH 2/4] fix(api-tokens): update lastUsedAt if it's null (#23870) --- .../core/admin/server/src/strategies/api-token.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/admin/server/src/strategies/api-token.ts b/packages/core/admin/server/src/strategies/api-token.ts index 17a3892983..0aecca86d3 100644 --- a/packages/core/admin/server/src/strategies/api-token.ts +++ b/packages/core/admin/server/src/strategies/api-token.ts @@ -54,10 +54,17 @@ export const authenticate = async (ctx: Context) => { } } - // update lastUsedAt if the token has not been used in the last hour - // @ts-expect-error - FIXME: verify lastUsedAt is defined - const hoursSinceLastUsed = differenceInHours(currentDate, parseISO(apiToken.lastUsedAt)); - if (hoursSinceLastUsed >= 1) { + if (!isNil(apiToken.lastUsedAt)) { + // update lastUsedAt if the token has not been used in the last hour + const hoursSinceLastUsed = differenceInHours(currentDate, parseISO(apiToken.lastUsedAt)); + if (hoursSinceLastUsed >= 1) { + await strapi.db.query('admin::api-token').update({ + where: { id: apiToken.id }, + data: { lastUsedAt: currentDate }, + }); + } + } else { + // If lastUsedAt is not set, initialize it to the current date await strapi.db.query('admin::api-token').update({ where: { id: apiToken.id }, data: { lastUsedAt: currentDate }, From 42d441cd5dbfd1bfb6d0fc015f0efaf5c2a1f395 Mon Sep 17 00:00:00 2001 From: Adrien L Date: Mon, 7 Jul 2025 11:29:12 +0200 Subject: [PATCH 3/4] feat: homepage widget assigned to me (#23848) --- .../admin/admin/src/components/Widgets.tsx | 2 +- .../core/admin/admin/src/core/apis/Widgets.ts | 2 +- .../core/admin/admin/src/features/Auth.tsx | 2 +- packages/core/admin/admin/src/utils/users.ts | 2 +- .../core/content-manager/admin/src/exports.ts | 1 + .../admin/src/services/documents.ts | 1 - .../server/src/homepage/services/homepage.ts | 161 ++++++++------- .../admin/src/components/Widgets.tsx | 134 ++++++++++++ .../src/components/tests/Widgets.test.tsx | 77 +++++++ .../core/review-workflows/admin/src/index.ts | 20 +- .../admin/src/services/content-manager.ts | 190 ++++++++++-------- .../admin/src/translations/en.json | 4 +- .../server/src/controllers/index.ts | 2 + .../src/homepage/controllers/homepage.ts | 14 ++ .../server/src/homepage/controllers/index.ts | 10 + .../server/src/homepage/index.ts | 9 + .../server/src/homepage/routes/homepage.ts | 20 ++ .../server/src/homepage/routes/index.ts | 6 + .../server/src/homepage/services/homepage.ts | 27 +++ .../server/src/homepage/services/index.ts | 7 + .../server/src/routes/index.ts | 2 + .../server/src/services/index.ts | 2 + .../shared/contracts/homepage.ts | 30 +++ tests/e2e/tests/review-workflows/home.spec.ts | 35 ++++ 24 files changed, 596 insertions(+), 164 deletions(-) create mode 100644 packages/core/review-workflows/admin/src/components/Widgets.tsx create mode 100644 packages/core/review-workflows/admin/src/components/tests/Widgets.test.tsx create mode 100644 packages/core/review-workflows/server/src/homepage/controllers/homepage.ts create mode 100644 packages/core/review-workflows/server/src/homepage/controllers/index.ts create mode 100644 packages/core/review-workflows/server/src/homepage/index.ts create mode 100644 packages/core/review-workflows/server/src/homepage/routes/homepage.ts create mode 100644 packages/core/review-workflows/server/src/homepage/routes/index.ts create mode 100644 packages/core/review-workflows/server/src/homepage/services/homepage.ts create mode 100644 packages/core/review-workflows/server/src/homepage/services/index.ts create mode 100644 packages/core/review-workflows/shared/contracts/homepage.ts create mode 100644 tests/e2e/tests/review-workflows/home.spec.ts diff --git a/packages/core/admin/admin/src/components/Widgets.tsx b/packages/core/admin/admin/src/components/Widgets.tsx index ca06e4e73c..2bd902e495 100644 --- a/packages/core/admin/admin/src/components/Widgets.tsx +++ b/packages/core/admin/admin/src/components/Widgets.tsx @@ -21,7 +21,7 @@ const ProfileWidget = () => { {userDisplayName && ( - + {userDisplayName} )} diff --git a/packages/core/admin/admin/src/core/apis/Widgets.ts b/packages/core/admin/admin/src/core/apis/Widgets.ts index 69b04abfb9..d104b8ca79 100644 --- a/packages/core/admin/admin/src/core/apis/Widgets.ts +++ b/packages/core/admin/admin/src/core/apis/Widgets.ts @@ -24,7 +24,7 @@ type WidgetArgs = { component: () => Promise; pluginId?: string; id: string; - permissions?: Permission[]; + permissions?: Array & Partial>>; }; type Widget = Omit & { uid: WidgetUID }; diff --git a/packages/core/admin/admin/src/features/Auth.tsx b/packages/core/admin/admin/src/features/Auth.tsx index 0d82a0abe5..17d626b570 100644 --- a/packages/core/admin/admin/src/features/Auth.tsx +++ b/packages/core/admin/admin/src/features/Auth.tsx @@ -45,7 +45,7 @@ interface AuthContextValue { * empty, the user does not have any of those permissions. */ checkUserHasPermissions: ( - permissions?: Permission[], + permissions?: Array & Partial>>, passedPermissions?: Permission[], rawQueryContext?: string ) => Promise; diff --git a/packages/core/admin/admin/src/utils/users.ts b/packages/core/admin/admin/src/utils/users.ts index 7d58bff2ea..91ce05b235 100644 --- a/packages/core/admin/admin/src/utils/users.ts +++ b/packages/core/admin/admin/src/utils/users.ts @@ -34,7 +34,7 @@ const getInitials = (user: Partial = {}): string => { .split(' ') .map((name) => name.substring(0, 1)) .join('') - .substring(0, 2) + .substring(0, 1) .toUpperCase(); }; diff --git a/packages/core/content-manager/admin/src/exports.ts b/packages/core/content-manager/admin/src/exports.ts index 4f548ab723..9585eba87b 100644 --- a/packages/core/content-manager/admin/src/exports.ts +++ b/packages/core/content-manager/admin/src/exports.ts @@ -6,6 +6,7 @@ export { buildValidParams } from './utils/api'; +export { RelativeTime } from './components/RelativeTime'; export { DocumentStatus } from './pages/EditView/components/DocumentStatus'; export { useDocument as unstable_useDocument, diff --git a/packages/core/content-manager/admin/src/services/documents.ts b/packages/core/content-manager/admin/src/services/documents.ts index e2724d58bc..72328cb456 100644 --- a/packages/core/content-manager/admin/src/services/documents.ts +++ b/packages/core/content-manager/admin/src/services/documents.ts @@ -379,7 +379,6 @@ const documentApi = contentManagerApi.injectEndpoints({ 'Relations', { type: 'UidAvailability', id: model }, 'RecentDocumentList', - 'RecentDocumentList', ]; }, async onQueryStarted({ data, ...patch }, { dispatch, queryFulfilled }) { diff --git a/packages/core/content-manager/server/src/homepage/services/homepage.ts b/packages/core/content-manager/server/src/homepage/services/homepage.ts index 17df1a43e2..944d0de811 100644 --- a/packages/core/content-manager/server/src/homepage/services/homepage.ts +++ b/packages/core/content-manager/server/src/homepage/services/homepage.ts @@ -93,8 +93,20 @@ const createHomepageService = ({ strapi }: { strapi: Core.Strapi }) => { }); }; - const formatDocuments = (documents: Modules.Documents.AnyDocument[], meta: ContentTypeMeta) => { + const formatDocuments = ( + documents: Modules.Documents.AnyDocument[], + meta: ContentTypeMeta, + populate?: string[] + ) => { return documents.map((document) => { + const additionalFields = + populate?.reduce( + (acc, key) => { + acc[key] = document[key]; + return acc; + }, + {} as Record + ) || {}; return { documentId: document.documentId, locale: document.locale ?? null, @@ -105,41 +117,11 @@ const createHomepageService = ({ strapi }: { strapi: Core.Strapi }) => { contentTypeUid: meta.uid, contentTypeDisplayName: meta.contentType.info.displayName, kind: meta.contentType.kind, + ...additionalFields, }; }); }; - const addStatusToDocuments = async (documents: RecentDocument[]): Promise => { - return Promise.all( - documents.map(async (recentDocument) => { - const hasDraftAndPublish = contentTypes.hasDraftAndPublish( - strapi.contentType(recentDocument.contentTypeUid) - ); - /** - * Tries to query the other version of the document if draft and publish is enabled, - * so that we know when to give the "modified" status. - */ - const { availableStatus } = await metadataService.getMetadata( - recentDocument.contentTypeUid, - recentDocument, - { - availableStatus: hasDraftAndPublish, - availableLocales: false, - } - ); - const status: RecentDocument['status'] = metadataService.getStatus( - recentDocument, - availableStatus - ); - - return { - ...recentDocument, - status: hasDraftAndPublish ? status : undefined, - }; - }) - ); - }; - const permissionCheckerService = strapi.plugin('content-manager').service('permission-checker'); const getPermissionChecker = (uid: string) => permissionCheckerService.create({ @@ -148,71 +130,108 @@ const createHomepageService = ({ strapi }: { strapi: Core.Strapi }) => { }); return { - async getRecentlyPublishedDocuments(): Promise { + async addStatusToDocuments(documents: RecentDocument[]): Promise { + return Promise.all( + documents.map(async (recentDocument) => { + const hasDraftAndPublish = contentTypes.hasDraftAndPublish( + strapi.contentType(recentDocument.contentTypeUid) + ); + /** + * Tries to query the other version of the document if draft and publish is enabled, + * so that we know when to give the "modified" status. + */ + const { availableStatus } = await metadataService.getMetadata( + recentDocument.contentTypeUid, + recentDocument, + { + availableStatus: hasDraftAndPublish, + availableLocales: false, + } + ); + const status: RecentDocument['status'] = metadataService.getStatus( + recentDocument, + availableStatus + ); + + return { + ...recentDocument, + status: hasDraftAndPublish ? status : undefined, + }; + }) + ); + }, + + async queryLastDocuments( + additionalQueryParams?: Record, + draftAndPublishOnly?: boolean + ): Promise { const permittedContentTypes = await getPermittedContentTypes(); - const allowedContentTypeUids = permittedContentTypes.filter((uid) => { - return contentTypes.hasDraftAndPublish(strapi.contentType(uid)); - }); + const allowedContentTypeUids = draftAndPublishOnly + ? permittedContentTypes.filter((uid) => { + return contentTypes.hasDraftAndPublish(strapi.contentType(uid)); + }) + : permittedContentTypes; // Fetch the configuration for each content type in a single query const configurations = await getConfiguration(allowedContentTypeUids); // Get the necessary metadata for the documents const contentTypesMeta = getContentTypesMeta(allowedContentTypeUids, configurations); - // Now actually fetch and format the documents + const recentDocuments = await Promise.all( contentTypesMeta.map(async (meta) => { const permissionQuery = await getPermissionChecker(meta.uid).sanitizedQuery.read({ limit: MAX_DOCUMENTS, - sort: 'publishedAt:desc', fields: meta.fields, - status: 'published', + ...additionalQueryParams, }); const docs = await strapi.documents(meta.uid).findMany(permissionQuery); + const populate = additionalQueryParams?.populate as string[]; - return formatDocuments(docs, meta); + return formatDocuments(docs, meta, populate); }) ); - const overallRecentDocuments = recentDocuments + return recentDocuments .flat() .sort((a, b) => { - if (!a.publishedAt || !b.publishedAt) return 0; - return b.publishedAt.valueOf() - a.publishedAt.valueOf(); + switch (additionalQueryParams?.sort) { + case 'publishedAt:desc': + if (!a.publishedAt || !b.publishedAt) return 0; + return b.publishedAt.valueOf() - a.publishedAt.valueOf(); + case 'publishedAt:asc': + if (!a.publishedAt || !b.publishedAt) return 0; + return a.publishedAt.valueOf() - b.publishedAt.valueOf(); + case 'updatedAt:desc': + if (!a.updatedAt || !b.updatedAt) return 0; + return b.updatedAt.valueOf() - a.updatedAt.valueOf(); + case 'updatedAt:asc': + if (!a.updatedAt || !b.updatedAt) return 0; + return a.updatedAt.valueOf() - b.updatedAt.valueOf(); + default: + return 0; + } }) .slice(0, MAX_DOCUMENTS); + }, - return addStatusToDocuments(overallRecentDocuments); + async getRecentlyPublishedDocuments(): Promise { + const recentlyPublishedDocuments = await this.queryLastDocuments( + { + sort: 'publishedAt:desc', + status: 'published', + }, + true + ); + + return this.addStatusToDocuments(recentlyPublishedDocuments); }, async getRecentlyUpdatedDocuments(): Promise { - const allowedContentTypeUids = await getPermittedContentTypes(); - // Fetch the configuration for each content type in a single query - const configurations = await getConfiguration(allowedContentTypeUids); - // Get the necessary metadata for the documents - const contentTypesMeta = getContentTypesMeta(allowedContentTypeUids, configurations); - // Now actually fetch and format the documents - const recentDocuments = await Promise.all( - contentTypesMeta.map(async (meta) => { - const permissionQuery = await getPermissionChecker(meta.uid).sanitizedQuery.read({ - limit: MAX_DOCUMENTS, - sort: 'updatedAt:desc', - fields: meta.fields, - }); + const recentlyUpdatedDocuments = await this.queryLastDocuments({ + sort: 'updatedAt:desc', + }); - const docs = await strapi.documents(meta.uid).findMany(permissionQuery); - - return formatDocuments(docs, meta); - }) - ); - - const overallRecentDocuments = recentDocuments - .flat() - .sort((a, b) => { - return b.updatedAt.valueOf() - a.updatedAt.valueOf(); - }) - .slice(0, MAX_DOCUMENTS); - - return addStatusToDocuments(overallRecentDocuments); + return this.addStatusToDocuments(recentlyUpdatedDocuments); }, }; }; diff --git a/packages/core/review-workflows/admin/src/components/Widgets.tsx b/packages/core/review-workflows/admin/src/components/Widgets.tsx new file mode 100644 index 0000000000..e85ff15750 --- /dev/null +++ b/packages/core/review-workflows/admin/src/components/Widgets.tsx @@ -0,0 +1,134 @@ +import { Widget, useTracking } from '@strapi/admin/strapi-admin'; +import { DocumentStatus, RelativeTime } from '@strapi/content-manager/strapi-admin'; +import { Box, IconButton, Table, Tbody, Td, Tr, Typography } from '@strapi/design-system'; +import { Pencil } from '@strapi/icons'; +import { useIntl } from 'react-intl'; +import { Link, useNavigate } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import { StageColumn } from '../routes/content-manager/model/components/TableColumns'; +import { useGetRecentlyAssignedDocumentsQuery } from '../services/content-manager'; + +import type { RecentDocument } from '../../../shared/contracts/homepage'; + +const CellTypography = styled(Typography).attrs({ maxWidth: '14.4rem', display: 'block' })` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RecentDocumentsTable = ({ documents }: { documents: RecentDocument[] }) => { + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + const navigate = useNavigate(); + + const getEditViewLink = (document: RecentDocument): string => { + const isSingleType = document.kind === 'singleType'; + const kindPath = isSingleType ? 'single-types' : 'collection-types'; + const queryParams = document.locale ? `?plugins[i18n][locale]=${document.locale}` : ''; + + return `/content-manager/${kindPath}/${document.contentTypeUid}${isSingleType ? '' : '/' + document.documentId}${queryParams}`; + }; + + const handleRowClick = (document: RecentDocument) => () => { + trackUsage('willEditEntryFromHome'); + const link = getEditViewLink(document); + navigate(link); + }; + + return ( + + + {documents?.map((document) => ( + + + + + + + + + ))} + +
+ + {document.title} + + + + {document.kind === 'singleType' + ? formatMessage({ + id: 'content-manager.widget.last-edited.single-type', + defaultMessage: 'Single-Type', + }) + : formatMessage({ + id: document.contentTypeDisplayName, + defaultMessage: document.contentTypeDisplayName, + })} + + + + {document.status ? ( + + ) : ( + + - + + )} + + + + + + + + e.stopPropagation()}> + + trackUsage('willEditEntryFromHome')} + label={formatMessage({ + id: 'content-manager.actions.edit.label', + defaultMessage: 'Edit', + })} + variant="ghost" + > + + + +
+ ); +}; + +/* ------------------------------------------------------------------------------------------------- + * AssignedWidget + * -----------------------------------------------------------------------------------------------*/ + +const AssignedWidget = () => { + const { formatMessage } = useIntl(); + const { data, isLoading, error } = useGetRecentlyAssignedDocumentsQuery(); + + if (isLoading) { + return ; + } + + if (error || !data) { + return ; + } + + if (data.length === 0) { + return ( + + {formatMessage({ + id: 'review-workflows.widget.assigned.no-data', + defaultMessage: 'No entries', + })} + + ); + } + + return ; +}; + +export { AssignedWidget }; diff --git a/packages/core/review-workflows/admin/src/components/tests/Widgets.test.tsx b/packages/core/review-workflows/admin/src/components/tests/Widgets.test.tsx new file mode 100644 index 0000000000..aa5741bc7e --- /dev/null +++ b/packages/core/review-workflows/admin/src/components/tests/Widgets.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@tests/utils'; + +import * as contentManager from '../../services/content-manager'; +import { AssignedWidget } from '../Widgets'; + +// Mock the useGetRecentlyAssignedDocumentsQuery hook +jest.mock('../../services/content-manager', () => ({ + useGetRecentlyAssignedDocumentsQuery: jest.fn(), +})); + +const mockDocuments = [ + { + documentId: '1', + title: 'Test Document', + kind: 'collectionType', + contentTypeUid: 'api::test.test', + contentTypeDisplayName: 'Test', + status: 'published', + updatedAt: '2024-05-01T12:00:00Z', + strapi_stage: { name: 'In review', color: 'blue' }, + locale: 'en', + }, +]; + +describe('AssignedWidget', () => { + it('renders a table with assigned documents', () => { + (contentManager.useGetRecentlyAssignedDocumentsQuery as jest.Mock).mockReturnValue({ + data: mockDocuments, + isLoading: false, + error: null, + }); + + render(); + expect(screen.getByText('Test Document')).toBeInTheDocument(); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('In review')).toBeInTheDocument(); + + const editLink = screen.getByRole('link', { name: /Edit/i }); + expect(editLink).toBeInTheDocument(); + expect(editLink).toHaveAttribute('href'); + expect(editLink).toHaveAttribute('href', expect.stringContaining(mockDocuments[0].documentId)); + }); + + it('shows loading state', () => { + (contentManager.useGetRecentlyAssignedDocumentsQuery as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }); + + render(); + expect(screen.getByText('Loading widget content')).toBeInTheDocument(); + }); + + it('shows error state', () => { + (contentManager.useGetRecentlyAssignedDocumentsQuery as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed'), + }); + + render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText("Couldn't load widget content.")).toBeInTheDocument(); + }); + + it('shows no data state', () => { + (contentManager.useGetRecentlyAssignedDocumentsQuery as jest.Mock).mockReturnValue({ + data: [], + isLoading: false, + error: null, + }); + + render(); + expect(screen.getByText(/No entries/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/review-workflows/admin/src/index.ts b/packages/core/review-workflows/admin/src/index.ts index 69cd79351e..2aa2277211 100644 --- a/packages/core/review-workflows/admin/src/index.ts +++ b/packages/core/review-workflows/admin/src/index.ts @@ -1,7 +1,8 @@ +import { SealCheck } from '@strapi/icons'; + import { PLUGIN_ID, FEATURE_ID } from './constants'; import { Header } from './routes/content-manager/model/id/components/Header'; import { Panel } from './routes/content-manager/model/id/components/Panel'; -import { StageSelect } from './routes/content-manager/model/id/components/StageSelect'; import { addColumnToTableHook } from './utils/cm-hooks'; import { prefixPluginTranslations } from './utils/translations'; @@ -35,6 +36,23 @@ const admin: Plugin.Config.AdminInput = { return { default: Router }; }, }); + + app.widgets.register([ + { + icon: SealCheck, + title: { + id: `${PLUGIN_ID}.widget.assigned.title`, + defaultMessage: 'Assigned to me', + }, + component: async () => { + const { AssignedWidget } = await import('./components/Widgets'); + return AssignedWidget; + }, + pluginId: PLUGIN_ID, + id: 'assigned', + permissions: [{ action: 'plugin::content-manager.explorer.read' }], + }, + ]); } else if (!window.strapi.features.isEnabled(FEATURE_ID) && window.strapi?.flags?.promoteEE) { app.addSettingsLink('global', { id: PLUGIN_ID, diff --git a/packages/core/review-workflows/admin/src/services/content-manager.ts b/packages/core/review-workflows/admin/src/services/content-manager.ts index 3e63b48ce3..a40fc17b1b 100644 --- a/packages/core/review-workflows/admin/src/services/content-manager.ts +++ b/packages/core/review-workflows/admin/src/services/content-manager.ts @@ -1,3 +1,5 @@ +import * as Homepage from '../../../shared/contracts/homepage'; + /* eslint-disable check-file/filename-naming-convention */ import { reviewWorkflowsApi } from './api'; @@ -16,106 +18,121 @@ interface ContentTypes { const SINGLE_TYPES = 'single-types'; -const contentManagerApi = reviewWorkflowsApi.injectEndpoints({ - endpoints: (builder) => ({ - getStages: builder.query< - { - stages: NonNullable; - meta: NonNullable; - }, - GetStages.Params & { slug: string; params?: object } - >({ - query: ({ model, slug, id, params }) => ({ - url: `/review-workflows/content-manager/${slug}/${model}/${id}/stages`, - method: 'GET', - config: { - params, +const contentManagerApi = reviewWorkflowsApi + .enhanceEndpoints({ + addTagTypes: ['RecentlyAssignedList', 'RecentDocumentList'], + }) + .injectEndpoints({ + endpoints: (builder) => ({ + getStages: builder.query< + { + stages: NonNullable; + meta: NonNullable; }, - }), - transformResponse: (res: GetStages.Response) => { - return { - meta: res.meta ?? { workflowCount: 0 }, - stages: res.data ?? [], - }; - }, - providesTags: ['ReviewWorkflowStages'], - }), - updateStage: builder.mutation< - UpdateStage.Response['data'], - UpdateStage.Request['body'] & UpdateStage.Params & { slug: string; params?: object } - >({ - query: ({ model, slug, id, params, ...data }) => ({ - url: `/review-workflows/content-manager/${slug}/${model}/${id}/stage`, - method: 'PUT', - data, - config: { - params, - }, - }), - transformResponse: (res: UpdateStage.Response) => res.data, - invalidatesTags: (_result, _error, { slug, id, model }) => { - return [ - { - type: 'Document', - id: slug !== SINGLE_TYPES ? `${model}_${id}` : model, + GetStages.Params & { slug: string; params?: object } + >({ + query: ({ model, slug, id, params }) => ({ + url: `/review-workflows/content-manager/${slug}/${model}/${id}/stages`, + method: 'GET', + config: { + params, }, - { type: 'Document', id: `${model}_LIST` }, - 'ReviewWorkflowStages', - ]; - }, - }), - updateAssignee: builder.mutation< - UpdateAssignee.Response['data'], - UpdateAssignee.Request['body'] & UpdateAssignee.Params & { slug: string; params?: object } - >({ - query: ({ model, slug, id, params, ...data }) => ({ - url: `/review-workflows/content-manager/${slug}/${model}/${id}/assignee`, - method: 'PUT', - data, - config: { - params, + }), + transformResponse: (res: GetStages.Response) => { + return { + meta: res.meta ?? { workflowCount: 0 }, + stages: res.data ?? [], + }; + }, + providesTags: ['ReviewWorkflowStages'], + }), + updateStage: builder.mutation< + UpdateStage.Response['data'], + UpdateStage.Request['body'] & UpdateStage.Params & { slug: string; params?: object } + >({ + query: ({ model, slug, id, params, ...data }) => ({ + url: `/review-workflows/content-manager/${slug}/${model}/${id}/stage`, + method: 'PUT', + data, + config: { + params, + }, + }), + transformResponse: (res: UpdateStage.Response) => res.data, + invalidatesTags: (_result, _error, { slug, id, model }) => { + return [ + { + type: 'Document', + id: slug !== SINGLE_TYPES ? `${model}_${id}` : model, + }, + { type: 'Document', id: `${model}_LIST` }, + 'ReviewWorkflowStages', + ]; }, }), - transformResponse: (res: UpdateAssignee.Response) => res.data, - invalidatesTags: (_result, _error, { slug, id, model }) => { - return [ - { - type: 'Document', - id: slug !== SINGLE_TYPES ? `${model}_${id}` : model, + updateAssignee: builder.mutation< + UpdateAssignee.Response['data'], + UpdateAssignee.Request['body'] & UpdateAssignee.Params & { slug: string; params?: object } + >({ + query: ({ model, slug, id, params, ...data }) => ({ + url: `/review-workflows/content-manager/${slug}/${model}/${id}/assignee`, + method: 'PUT', + data, + config: { + params, }, - { type: 'Document', id: `${model}_LIST` }, - ]; - }, - }), - getContentTypes: builder.query({ - query: () => ({ - url: `/content-manager/content-types`, - method: 'GET', + }), + transformResponse: (res: UpdateAssignee.Response) => res.data, + invalidatesTags: (_result, _error, { slug, id, model }) => { + return [ + { + type: 'Document', + id: slug !== SINGLE_TYPES ? `${model}_${id}` : model, + }, + { type: 'Document', id: `${model}_LIST` }, + 'RecentlyAssignedList', + ]; + }, }), - transformResponse: (res: { data: Contracts.ContentTypes.ContentType[] }) => { - return res.data.reduce( - (acc, curr) => { - if (curr.isDisplayed) { - acc[curr.kind].push(curr); + getContentTypes: builder.query({ + query: () => ({ + url: `/content-manager/content-types`, + method: 'GET', + }), + transformResponse: (res: { data: Contracts.ContentTypes.ContentType[] }) => { + return res.data.reduce( + (acc, curr) => { + if (curr.isDisplayed) { + acc[curr.kind].push(curr); + } + return acc; + }, + { + collectionType: [], + singleType: [], } - return acc; - }, - { - collectionType: [], - singleType: [], - } - ); - }, + ); + }, + }), + getRecentlyAssignedDocuments: builder.query< + Homepage.GetRecentlyAssignedDocuments.Response['data'], + void + >({ + query: () => '/review-workflows/homepage/recently-assigned-documents', + transformResponse: (response: Homepage.GetRecentlyAssignedDocuments.Response) => + response.data, + providesTags: (_, _err) => ['RecentlyAssignedList', 'RecentDocumentList'], + }), }), - }), - overrideExisting: true, -}); + overrideExisting: true, + }); const { useGetStagesQuery, useUpdateStageMutation, useUpdateAssigneeMutation, useGetContentTypesQuery, + useGetRecentlyAssignedDocumentsQuery, } = contentManagerApi; export { @@ -123,5 +140,6 @@ export { useUpdateStageMutation, useUpdateAssigneeMutation, useGetContentTypesQuery, + useGetRecentlyAssignedDocumentsQuery, }; export type { ContentTypes, ContentType }; diff --git a/packages/core/review-workflows/admin/src/translations/en.json b/packages/core/review-workflows/admin/src/translations/en.json index a5f65405ee..39fc69a58f 100644 --- a/packages/core/review-workflows/admin/src/translations/en.json +++ b/packages/core/review-workflows/admin/src/translations/en.json @@ -11,5 +11,7 @@ "settings.page.purchase.description": "Manage your content review process", "settings.page.purchase.perks1": "Customizable review stages", "settings.page.purchase.perks2": "Manage user permissions", - "settings.page.purchase.perks3": "Support for webhooks" + "settings.page.purchase.perks3": "Support for webhooks", + "widget.assigned.title": "Assigned to me", + "widget.assigned.no-data": "No entries" } diff --git a/packages/core/review-workflows/server/src/controllers/index.ts b/packages/core/review-workflows/server/src/controllers/index.ts index 9b16f7b792..cd05aa6ad9 100644 --- a/packages/core/review-workflows/server/src/controllers/index.ts +++ b/packages/core/review-workflows/server/src/controllers/index.ts @@ -3,9 +3,11 @@ import type {} from 'koa-body'; import workflows from './workflows'; import stages from './stages'; import assignees from './assignees'; +import homepage from '../homepage'; export default { workflows, stages, assignees, + ...homepage.controllers, }; diff --git a/packages/core/review-workflows/server/src/homepage/controllers/homepage.ts b/packages/core/review-workflows/server/src/homepage/controllers/homepage.ts new file mode 100644 index 0000000000..085567ff43 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/controllers/homepage.ts @@ -0,0 +1,14 @@ +import type { Core } from '@strapi/types'; +import type { GetRecentlyAssignedDocuments } from '../../../../shared/contracts/homepage'; + +const createHomepageController = () => { + const homepageService = strapi.plugin('review-workflows').service('homepage'); + + return { + async getRecentlyAssignedDocuments(): Promise { + return { data: await homepageService.getRecentlyAssignedDocuments() }; + }, + } satisfies Core.Controller; +}; + +export { createHomepageController }; diff --git a/packages/core/review-workflows/server/src/homepage/controllers/index.ts b/packages/core/review-workflows/server/src/homepage/controllers/index.ts new file mode 100644 index 0000000000..c65d849978 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/controllers/index.ts @@ -0,0 +1,10 @@ +import type { Plugin } from '@strapi/types'; +import { createHomepageController } from './homepage'; + +export const controllers = { + homepage: createHomepageController, + /** + * Casting is needed because the types aren't aware that Strapi supports + * passing a controller factory as the value, instead of a controller object directly + */ +} as unknown as Plugin.LoadedPlugin['controllers']; diff --git a/packages/core/review-workflows/server/src/homepage/index.ts b/packages/core/review-workflows/server/src/homepage/index.ts new file mode 100644 index 0000000000..f3c8c10a39 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/index.ts @@ -0,0 +1,9 @@ +import { routes } from './routes'; +import { controllers } from './controllers'; +import { services } from './services'; + +export default { + routes, + controllers, + services, +}; diff --git a/packages/core/review-workflows/server/src/homepage/routes/homepage.ts b/packages/core/review-workflows/server/src/homepage/routes/homepage.ts new file mode 100644 index 0000000000..a016c1d367 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/routes/homepage.ts @@ -0,0 +1,20 @@ +import type { Plugin } from '@strapi/types'; + +const info = { pluginName: 'content-manager', type: 'admin' }; + +const homepageRouter: Plugin.LoadedPlugin['routes'][string] = { + type: 'admin', + routes: [ + { + method: 'GET', + info, + path: '/homepage/recently-assigned-documents', + handler: 'homepage.getRecentlyAssignedDocuments', + config: { + policies: ['admin::isAuthenticatedAdmin'], + }, + }, + ], +}; + +export { homepageRouter }; diff --git a/packages/core/review-workflows/server/src/homepage/routes/index.ts b/packages/core/review-workflows/server/src/homepage/routes/index.ts new file mode 100644 index 0000000000..520f7fa766 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/routes/index.ts @@ -0,0 +1,6 @@ +import type { Plugin } from '@strapi/types'; +import { homepageRouter } from './homepage'; + +export const routes = { + homepage: homepageRouter, +} satisfies Plugin.LoadedPlugin['routes']; diff --git a/packages/core/review-workflows/server/src/homepage/services/homepage.ts b/packages/core/review-workflows/server/src/homepage/services/homepage.ts new file mode 100644 index 0000000000..f48769d8e9 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/services/homepage.ts @@ -0,0 +1,27 @@ +import type { Core } from '@strapi/types'; + +import type { GetRecentlyAssignedDocuments } from '../../../../shared/contracts/homepage'; + +const createHomepageService = ({ strapi }: { strapi: Core.Strapi }) => { + return { + async getRecentlyAssignedDocuments(): Promise { + const userId = strapi.requestContext.get()?.state?.user.id; + const { queryLastDocuments, addStatusToDocuments } = strapi + .plugin('content-manager') + .service('homepage'); + + const recentlyAssignedDocuments = await queryLastDocuments({ + populate: ['strapi_stage'], + filters: { + strapi_assignee: { + id: userId, + }, + }, + }); + + return addStatusToDocuments(recentlyAssignedDocuments); + }, + }; +}; + +export { createHomepageService }; diff --git a/packages/core/review-workflows/server/src/homepage/services/index.ts b/packages/core/review-workflows/server/src/homepage/services/index.ts new file mode 100644 index 0000000000..4901193b60 --- /dev/null +++ b/packages/core/review-workflows/server/src/homepage/services/index.ts @@ -0,0 +1,7 @@ +import type { Plugin } from '@strapi/types'; + +import { createHomepageService } from './homepage'; + +export const services = { + homepage: createHomepageService, +} satisfies Plugin.LoadedPlugin['services']; diff --git a/packages/core/review-workflows/server/src/routes/index.ts b/packages/core/review-workflows/server/src/routes/index.ts index 5a010efbd8..f8d63415e0 100644 --- a/packages/core/review-workflows/server/src/routes/index.ts +++ b/packages/core/review-workflows/server/src/routes/index.ts @@ -1,5 +1,7 @@ import reviewWorkflows from './review-workflows'; +import homepage from '../homepage'; export default { 'review-workflows': reviewWorkflows, + ...homepage.routes, }; diff --git a/packages/core/review-workflows/server/src/services/index.ts b/packages/core/review-workflows/server/src/services/index.ts index 08d239fcad..946ed391ac 100644 --- a/packages/core/review-workflows/server/src/services/index.ts +++ b/packages/core/review-workflows/server/src/services/index.ts @@ -6,6 +6,7 @@ import reviewWorkflowsValidation from './validation'; import reviewWorkflowsMetrics from './metrics'; import reviewWorkflowsWeeklyMetrics from './metrics/weekly-metrics'; import documentServiceMiddleware from './document-service-middleware'; +import homepage from '../homepage'; export default { workflows, @@ -16,4 +17,5 @@ export default { 'document-service-middlewares': documentServiceMiddleware, 'workflow-metrics': reviewWorkflowsMetrics, 'workflow-weekly-metrics': reviewWorkflowsWeeklyMetrics, + ...homepage.services, }; diff --git a/packages/core/review-workflows/shared/contracts/homepage.ts b/packages/core/review-workflows/shared/contracts/homepage.ts new file mode 100644 index 0000000000..56fb89c30a --- /dev/null +++ b/packages/core/review-workflows/shared/contracts/homepage.ts @@ -0,0 +1,30 @@ +import type { errors } from '@strapi/utils'; +import type { Struct, UID } from '@strapi/types'; + +// Export required to avoid "cannot be named" TS build error +export interface RecentDocument { + kind: Struct.ContentTypeKind; + contentTypeUid: UID.ContentType; + contentTypeDisplayName: string; + documentId: string; + locale: string | null; + status?: 'draft' | 'published' | 'modified'; + title: string; + updatedAt: Date; + publishedAt?: Date | null; + strapi_stage?: { + color?: string; + name: string; + }; +} + +export declare namespace GetRecentlyAssignedDocuments { + export interface Request { + body: {}; + } + + export interface Response { + data: RecentDocument[]; + error?: errors.ApplicationError; + } +} diff --git a/tests/e2e/tests/review-workflows/home.spec.ts b/tests/e2e/tests/review-workflows/home.spec.ts new file mode 100644 index 0000000000..67d05d0977 --- /dev/null +++ b/tests/e2e/tests/review-workflows/home.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { login } from '../../utils/login'; +import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import'; +import { clickAndWait, describeOnCondition, findAndClose, navToHeader } from '../../utils/shared'; + +const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; + +describeOnCondition(edition === 'EE')('Home', () => { + test.beforeEach(async ({ page }) => { + await resetDatabaseAndImportDataFromPath('with-admin.tar'); + await page.goto('/admin'); + await login({ page }); + }); + + test('a user should see the last entries assigned to them', async ({ page }) => { + const assignedWidget = page.getByLabel(/assigned to me/i); + await expect(assignedWidget).toBeVisible(); + + // Make content update in the CM and assign it to the current user + await navToHeader(page, ['Content Manager'], 'Article'); + await clickAndWait(page, page.getByRole('gridcell', { name: /^west ham/i })); + + // Assign the entry to the current user + await page.getByRole('combobox', { name: 'Assignee' }).click(); + await page.getByRole('option', { name: 'test testing' }).click(); + await findAndClose(page, 'Assignee updated'); + + // Go back to the home page, the assigned entry should be visible in the assigned widget + await clickAndWait(page, page.getByRole('link', { name: /^home$/i })); + const assignedEntry = assignedWidget.getByRole('row').nth(0); + await expect(assignedEntry).toBeVisible(); + await expect(assignedEntry.getByRole('gridcell', { name: /^west ham/i })).toBeVisible(); + await expect(assignedEntry.getByRole('gridcell', { name: /draft/i })).toBeVisible(); + }); +}); From 15aa51942723e4509a1fa9992c8dc4a81794cb1d Mon Sep 17 00:00:00 2001 From: Ziyi Date: Mon, 7 Jul 2025 14:13:33 +0200 Subject: [PATCH 4/4] fix: fix condition loss on relation field (#23885) * fix: fix condition loss on relation field * fix: add comments explaination and fix prettier issue --- .../components/DataManager/utils/cleanData.ts | 7 +- .../src/components/FormModal/FormModal.tsx | 16 +- .../content-type-builder/admin/src/types.ts | 3 + .../server/src/controllers/schema.ts | 3 +- .../schema-builder/content-type-builder.ts | 144 +++++++++++++----- .../src/services/schema-builder/index.ts | 4 +- .../src/services/schema-builder/types.ts | 26 ++++ .../server/src/services/schema.ts | 2 + 8 files changed, 163 insertions(+), 42 deletions(-) create mode 100644 packages/core/content-type-builder/server/src/services/schema-builder/types.ts diff --git a/packages/core/content-type-builder/admin/src/components/DataManager/utils/cleanData.ts b/packages/core/content-type-builder/admin/src/components/DataManager/utils/cleanData.ts index 491cf8b6e2..4344e21865 100644 --- a/packages/core/content-type-builder/admin/src/components/DataManager/utils/cleanData.ts +++ b/packages/core/content-type-builder/admin/src/components/DataManager/utils/cleanData.ts @@ -126,7 +126,12 @@ const formatAttribute = (attr: AnyAttribute) => { } if ('targetAttribute' in attr) { - return { ...attr, targetAttribute: attr.targetAttribute === '-' ? null : attr.targetAttribute }; + return { + ...attr, + targetAttribute: attr.targetAttribute === '-' ? null : attr.targetAttribute, + // Explicitly preserve conditions for relations + ...(attr.conditions && { conditions: attr.conditions }), + }; } return attr; diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx b/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx index 1762b84a91..8dff70410b 100644 --- a/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx +++ b/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx @@ -739,8 +739,22 @@ export const FormModal = () => { targetUid, }); } else { + // Ensure conditions are explicitly set to undefined if they were removed + // Explicitly set conditions to undefined when they're removed to distinguish between: + // 1. missing property: "don't change existing conditions" (partial update) + // 2. undefined property: "delete conditions" (explicit removal) + // This allows the backend to detect user intent: + // { name: "field" } vs { name: "field", conditions: undefined } + // without this, deleted conditions would be preserved by the backend's + // reuseUnsetPreviousProperties function. + const attributeData = { ...modifiedData }; + if (!('conditions' in modifiedData) || modifiedData.conditions === undefined) { + // Explicitly add the conditions key with undefined value + attributeData.conditions = undefined; + } + editAttribute({ - attributeToSet: modifiedData, + attributeToSet: attributeData, forTarget, targetUid, name: initialData.name, diff --git a/packages/core/content-type-builder/admin/src/types.ts b/packages/core/content-type-builder/admin/src/types.ts index 51ffe09974..67644963b0 100644 --- a/packages/core/content-type-builder/admin/src/types.ts +++ b/packages/core/content-type-builder/admin/src/types.ts @@ -20,6 +20,9 @@ export type Base = { name: string; status?: Status; customField?: any; + conditions?: { + visible?: Record; + }; }; export type Relation = Base & { diff --git a/packages/core/content-type-builder/server/src/controllers/schema.ts b/packages/core/content-type-builder/server/src/controllers/schema.ts index 20f1ed48b6..98f83b5a8b 100644 --- a/packages/core/content-type-builder/server/src/controllers/schema.ts +++ b/packages/core/content-type-builder/server/src/controllers/schema.ts @@ -43,7 +43,8 @@ export default () => { ctx.body = {}; } catch (error) { internals.isUpdating = false; - return ctx.send({ error }, 400); + const errorMessage = error instanceof Error ? error.message : String(error); + return ctx.send({ error: errorMessage }, 400); } }, diff --git a/packages/core/content-type-builder/server/src/services/schema-builder/content-type-builder.ts b/packages/core/content-type-builder/server/src/services/schema-builder/content-type-builder.ts index 205a26fdd6..2300e9079f 100644 --- a/packages/core/content-type-builder/server/src/services/schema-builder/content-type-builder.ts +++ b/packages/core/content-type-builder/server/src/services/schema-builder/content-type-builder.ts @@ -7,6 +7,7 @@ import { isRelation, isConfigurable } from '../../utils/attributes'; import { typeKinds } from '../constants'; import createSchemaHandler from './schema-handler'; import { CreateContentTypeInput } from '../../controllers/validation/content-type'; +import type { InternalRelationAttribute, InternalAttribute } from './types'; const { ApplicationError } = errors; @@ -24,13 +25,17 @@ const reuseUnsetPreviousProperties = ( 'pluginOptions', 'inversedBy', 'mappedBy', + 'conditions', // Don't automatically preserve conditions ]) ); }; export default function createComponentBuilder() { return { - setRelation(this: any, { key, uid, attribute }: any) { + setRelation( + this: any, + { key, uid, attribute }: { key: string; uid: string; attribute: InternalRelationAttribute } + ) { if (!_.has(attribute, 'target')) { return; } @@ -47,20 +52,30 @@ export default function createComponentBuilder() { return; } + // When generating the inverse relation, preserve existing conditions if they exist + // If the target attribute already exists and has conditions, preserve them + const targetAttributeData = targetAttribute || {}; + + // If the source doesn't have conditions but the target does, preserve target's conditions + targetCT.setAttribute( attribute.targetAttribute, - generateRelation({ key, attribute, uid, targetAttribute }) + generateRelation({ key, attribute, uid, targetAttribute: targetAttributeData }) ); }, - unsetRelation(this: any, attribute: any) { - if (!_.has(attribute, 'target')) { + unsetRelation( + this: any, + attribute: Schema.Attribute.Relation + ) { + if (!('target' in attribute) || !attribute.target) { return; } const targetCT = this.contentTypes.get(attribute.target); - const targetAttributeName = attribute.inversedBy || attribute.mappedBy; + const relationAttribute = attribute as InternalRelationAttribute; + const targetAttributeName = relationAttribute.inversedBy || relationAttribute.mappedBy; const targetAttribute = targetCT.getAttribute(targetAttributeName); if (!targetAttribute) return; @@ -90,28 +105,34 @@ export default function createComponentBuilder() { contentType.setAttributes(this.convertAttributes(attributes)); Object.keys(attributes).forEach((key) => { - const attribute = attributes[key]; + const attribute = attributes[key] as InternalAttribute; if (isRelation(attribute)) { - if (['manyToMany', 'oneToOne'].includes(attribute.relation)) { - if (attribute.target === uid && attribute.targetAttribute !== undefined) { + const relationAttribute = attribute as InternalRelationAttribute; + if (['manyToMany', 'oneToOne'].includes(relationAttribute.relation)) { + if ( + relationAttribute.target === uid && + relationAttribute.targetAttribute !== undefined + ) { // self referencing relation - const targetAttribute = attributes[attribute.targetAttribute]; + const targetAttribute = attributes[ + relationAttribute.targetAttribute + ] as InternalRelationAttribute; if (targetAttribute.dominant === undefined) { - attribute.dominant = true; + relationAttribute.dominant = true; } else { - attribute.dominant = false; + relationAttribute.dominant = false; } } else { - attribute.dominant = true; + relationAttribute.dominant = true; } } this.setRelation({ key, uid, - attribute, + attribute: relationAttribute, }); } }); @@ -196,23 +217,26 @@ export default function createComponentBuilder() { deletedKeys.forEach((key) => { const attribute = oldAttributes[key]; - const targetAttributeName = attribute.inversedBy || attribute.mappedBy; - // if the old relation has a target attribute. we need to remove it in the target type - if (isConfigurable(attribute) && isRelation(attribute) && !_.isNil(targetAttributeName)) { - this.unsetRelation(attribute); + if (isConfigurable(attribute) && isRelation(attribute)) { + const relationAttribute = attribute as InternalRelationAttribute; + const targetAttributeName = relationAttribute.inversedBy || relationAttribute.mappedBy; + + if (targetAttributeName !== null && targetAttributeName !== undefined) { + this.unsetRelation(attribute); + } } }); remainingKeys.forEach((key) => { const oldAttribute = oldAttributes[key]; - const newAttribute = newAttributes[key]; + const newAttribute = newAttributes[key] as InternalAttribute; if (!isRelation(oldAttribute) && isRelation(newAttribute)) { return this.setRelation({ key, uid, - attribute: newAttributes[key], + attribute: newAttribute as InternalRelationAttribute, }); } @@ -221,56 +245,85 @@ export default function createComponentBuilder() { } if (isRelation(oldAttribute) && isRelation(newAttribute)) { - const oldTargetAttributeName = oldAttribute.inversedBy || oldAttribute.mappedBy; + const relationAttribute = newAttribute as InternalRelationAttribute; + const oldRelationAttribute = oldAttribute as InternalRelationAttribute; + const oldTargetAttributeName = + oldRelationAttribute.inversedBy || oldRelationAttribute.mappedBy; - const sameRelation = oldAttribute.relation === newAttribute.relation; - const targetAttributeHasChanged = oldTargetAttributeName !== newAttribute.targetAttribute; + const sameRelation = oldAttribute.relation === relationAttribute.relation; + const targetAttributeHasChanged = + oldTargetAttributeName !== relationAttribute.targetAttribute; if (!sameRelation || targetAttributeHasChanged) { this.unsetRelation(oldAttribute); } // keep extra options that were set manually on oldAttribute - reuseUnsetPreviousProperties(newAttribute, oldAttribute); + reuseUnsetPreviousProperties(relationAttribute, oldAttribute); - if (oldAttribute.inversedBy) { - newAttribute.dominant = true; - } else if (oldAttribute.mappedBy) { - newAttribute.dominant = false; + // Handle conditions explicitly - only preserve if present and not undefined in new attribute + const newAttributeFromInfos = newAttributes[key]; + const hasNewConditions = + newAttributeFromInfos.conditions !== undefined && + newAttributeFromInfos.conditions !== null; + + if (oldAttribute.conditions) { + if (hasNewConditions) { + // Conditions are still present, keep them + relationAttribute.conditions = newAttributeFromInfos.conditions; + } else { + // Conditions were removed (undefined or null), ensure they're not preserved + delete relationAttribute.conditions; + } + } else if (hasNewConditions) { + // New conditions added + relationAttribute.conditions = newAttributeFromInfos.conditions; + } + + if (oldRelationAttribute.inversedBy) { + relationAttribute.dominant = true; + } else if (oldRelationAttribute.mappedBy) { + relationAttribute.dominant = false; } return this.setRelation({ key, uid, - attribute: newAttribute, + attribute: relationAttribute, }); } }); // add new relations newKeys.forEach((key) => { - const attribute = newAttributes[key]; + const attribute = newAttributes[key] as InternalAttribute; if (isRelation(attribute)) { - if (['manyToMany', 'oneToOne'].includes(attribute.relation)) { - if (attribute.target === uid && attribute.targetAttribute !== undefined) { + const relationAttribute = attribute as InternalRelationAttribute; + if (['manyToMany', 'oneToOne'].includes(relationAttribute.relation)) { + if ( + relationAttribute.target === uid && + relationAttribute.targetAttribute !== undefined + ) { // self referencing relation - const targetAttribute = newAttributes[attribute.targetAttribute]; + const targetAttribute = newAttributes[ + relationAttribute.targetAttribute + ] as InternalRelationAttribute; if (targetAttribute.dominant === undefined) { - attribute.dominant = true; + relationAttribute.dominant = true; } else { - attribute.dominant = false; + relationAttribute.dominant = false; } } else { - attribute.dominant = true; + relationAttribute.dominant = true; } } this.setRelation({ key, uid, - attribute, + attribute: relationAttribute, }); } }); @@ -320,12 +373,25 @@ const createContentTypeUID = ({ singularName: string; }): Internal.UID.ContentType => `api::${singularName}.${singularName}`; -const generateRelation = ({ key, attribute, uid, targetAttribute = {} }: any) => { +const generateRelation = ({ + key, + attribute, + uid, + targetAttribute = {}, +}: { + key: string; + attribute: InternalRelationAttribute; + uid: string; + targetAttribute?: Partial; +}) => { const opts: any = { type: 'relation', target: uid, private: targetAttribute.private || undefined, pluginOptions: targetAttribute.pluginOptions || undefined, + // Preserve conditions from targetAttribute if they exist + // This allows each side of the relation to maintain its own conditions + ...(targetAttribute.conditions && { conditions: targetAttribute.conditions }), }; switch (attribute.relation) { @@ -366,10 +432,12 @@ const generateRelation = ({ key, attribute, uid, targetAttribute = {} }: any) => // we do this just to make sure we have the same key order when writing to files const { type, relation, target, ...restOptions } = opts; - return { + const result = { type, relation, target, ...restOptions, }; + + return result; }; diff --git a/packages/core/content-type-builder/server/src/services/schema-builder/index.ts b/packages/core/content-type-builder/server/src/services/schema-builder/index.ts index 98e5e726f6..2d79c46c68 100644 --- a/packages/core/content-type-builder/server/src/services/schema-builder/index.ts +++ b/packages/core/content-type-builder/server/src/services/schema-builder/index.ts @@ -94,11 +94,13 @@ function createSchemaBuilder({ components, contentTypes }: SchemaBuilderOptions) }, convertAttribute(attribute: any) { - const { configurable, private: isPrivate } = attribute; + const { configurable, private: isPrivate, conditions } = attribute; const baseProperties = { private: isPrivate === true ? true : undefined, configurable: configurable === false ? false : undefined, + // IMPORTANT: Preserve conditions only if they exist and are not undefined/null + ...(conditions !== undefined && conditions !== null && { conditions }), }; if (attribute.type === 'relation') { diff --git a/packages/core/content-type-builder/server/src/services/schema-builder/types.ts b/packages/core/content-type-builder/server/src/services/schema-builder/types.ts new file mode 100644 index 0000000000..ea1a85b856 --- /dev/null +++ b/packages/core/content-type-builder/server/src/services/schema-builder/types.ts @@ -0,0 +1,26 @@ +import type { Schema } from '@strapi/types'; + +/** + * Internal relation attribute type that includes the dominant property + * used during schema building process + */ +export type InternalRelationAttribute = Schema.Attribute.Relation & { + dominant?: boolean; + target: string; + targetAttribute?: string; + relation: Schema.Attribute.RelationKind.Any; + inversedBy?: string; + mappedBy?: string; + private?: boolean; + pluginOptions?: object; + conditions?: { + visible: Record; + }; +}; + +/** + * Internal attribute type that can be any attribute or a relation with dominant property + */ +export type InternalAttribute = + | Exclude + | InternalRelationAttribute; diff --git a/packages/core/content-type-builder/server/src/services/schema.ts b/packages/core/content-type-builder/server/src/services/schema.ts index 30e1ce3939..9b60331c71 100644 --- a/packages/core/content-type-builder/server/src/services/schema.ts +++ b/packages/core/content-type-builder/server/src/services/schema.ts @@ -78,6 +78,8 @@ export const formatAttribute = (attribute: Schema.Attribute.AnyAttribute & Recor return { ...attribute, targetAttribute: attribute.inversedBy || attribute.mappedBy || null, + // Explicitly preserve conditions if they exist + ...(attribute.conditions && { conditions: attribute.conditions }), }; }