Merge branch 'develop' into future/guided-tour-add-popover-arrow-disable-old-tour

This commit is contained in:
Simone Taeggi 2025-07-07 16:23:10 +02:00
commit 4ef41514bc
40 changed files with 1594 additions and 325 deletions

View File

@ -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<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
@ -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<State>(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 (
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
@ -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 };

View File

@ -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>
);
};

View File

@ -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';
@ -94,7 +94,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;
@ -127,10 +126,9 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
)}
{to ? (
<LinkButton
onClick={() => {
dispatch({ type: 'next_step', payload: tourName });
navigate(to);
}}
tag={NavLink}
to={to}
onClick={() => dispatch({ type: 'next_step', payload: tourName })}
>
<FormattedMessage id="tours.next" defaultMessage="Next" />
</LinkButton>

View File

@ -101,6 +101,67 @@ const tours = {
when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'),
},
]),
contentManager: createTour('contentManager', [
{
name: 'Introduction',
content: (Step) => (
<Step.Root side="top">
<Step.Title
id="tours.contentManager.Introduction.title"
defaultMessage="Content manager"
/>
<Step.Content
id="tours.contentManager.Introduction.content"
defaultMessage="Create and manage content from your collection types and single types."
/>
<Step.Actions showSkip />
</Step.Root>
),
},
{
name: 'Fields',
content: (Step) => (
<Step.Root side={'top'} 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="Its time to create API Tokens!"
/>
<Step.Content
id="tours.contentManager.FinalStep.content"
defaultMessage="Now that youve 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',
@ -181,67 +242,7 @@ const tours = {
when: (completedActions) => completedActions.includes('didCopyApiToken'),
},
]),
contentManager: createTour('contentManager', [
{
name: 'Introduction',
content: (Step) => (
<Step.Root side="top">
<Step.Title
id="tours.contentManager.Introduction.title"
defaultMessage="Content manager"
/>
<Step.Content
id="tours.contentManager.Introduction.content"
defaultMessage="Create and manage content from your collection types and single types."
/>
<Step.Actions showSkip />
</Step.Root>
),
},
{
name: 'Fields',
content: (Step) => (
<Step.Root side={'top'} 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="Its time to create API Tokens!"
/>
<Step.Content
id="tours.contentManager.FinalStep.content"
defaultMessage="Now that youve 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'),
},
]),
strapiCloud: createTour('strapiCloud', []),
} as const;
type Tours = typeof tours;

View File

@ -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);

View File

@ -21,7 +21,7 @@ const ProfileWidget = () => {
<Flex direction="column" gap={3} height="100%" justifyContent="center">
<Avatar.Item delayMs={0} fallback={initials} />
{userDisplayName && (
<DisplayNameTypography fontWeight="bold" textTransform="none">
<DisplayNameTypography fontWeight="bold" textTransform="none" textAlign="center">
{userDisplayName}
</DisplayNameTypography>
)}

View File

@ -24,7 +24,7 @@ type WidgetArgs = {
component: () => Promise<React.ComponentType>;
pluginId?: string;
id: string;
permissions?: Permission[];
permissions?: Array<Pick<Permission, 'action'> & Partial<Omit<Permission, 'action'>>>;
};
type Widget = Omit<WidgetArgs, 'id' | 'pluginId'> & { uid: WidgetUID };

View File

@ -45,7 +45,7 @@ interface AuthContextValue {
* empty, the user does not have any of those permissions.
*/
checkUserHasPermissions: (
permissions?: Permission[],
permissions?: Array<Pick<Permission, 'action'> & Partial<Omit<Permission, 'action'>>>,
passedPermissions?: Permission[],
rawQueryContext?: string
) => Promise<Permission[]>;

View File

@ -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 = () => {
<FreeTrialEndedModal />
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={8} paddingBottom={10}>
<GuidedTour />
{window.strapi.future.isEnabled('unstableGuidedTour') ? (
<UnstableGuidedTourOverview />
) : (
<GuidedTour />
)}
<Grid.Root gap={5}>
{getAllWidgets().map((widget) => {
return (

View File

@ -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"
}

View File

@ -34,7 +34,7 @@ const getInitials = (user: Partial<User> = {}): string => {
.split(' ')
.map((name) => name.substring(0, 1))
.join('')
.substring(0, 2)
.substring(0, 1)
.toUpperCase();
};

View File

@ -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 },

View File

@ -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,

View File

@ -379,7 +379,6 @@ const documentApi = contentManagerApi.injectEndpoints({
'Relations',
{ type: 'UidAvailability', id: model },
'RecentDocumentList',
'RecentDocumentList',
];
},
async onQueryStarted({ data, ...patch }, { dispatch, queryFulfilled }) {

View File

@ -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<string, any>
) || {};
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<RecentDocument[]> => {
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<GetRecentDocuments.Response['data']> {
async addStatusToDocuments(documents: RecentDocument[]): Promise<RecentDocument[]> {
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<string, unknown>,
draftAndPublishOnly?: boolean
): Promise<RecentDocument[]> {
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<GetRecentDocuments.Response['data']> {
const recentlyPublishedDocuments = await this.queryLastDocuments(
{
sort: 'publishedAt:desc',
status: 'published',
},
true
);
return this.addStatusToDocuments(recentlyPublishedDocuments);
},
async getRecentlyUpdatedDocuments(): Promise<GetRecentDocuments.Response['data']> {
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);
},
};
};

View File

@ -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;

View File

@ -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,

View File

@ -20,6 +20,9 @@ export type Base = {
name: string;
status?: Status;
customField?: any;
conditions?: {
visible?: Record<string, any>;
};
};
export type Relation = Base & {

View File

@ -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);
}
},

View File

@ -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<Schema.Attribute.RelationKind.Any>
) {
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<InternalRelationAttribute>;
}) => {
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;
};

View File

@ -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') {

View File

@ -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<string, any>;
};
};
/**
* Internal attribute type that can be any attribute or a relation with dominant property
*/
export type InternalAttribute =
| Exclude<Schema.Attribute.AnyAttribute, Schema.Attribute.Relation>
| InternalRelationAttribute;

View File

@ -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 }),
};
}

View File

@ -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 (
<Table colCount={6} rowCount={documents?.length ?? 0}>
<Tbody>
{documents?.map((document) => (
<Tr onClick={handleRowClick(document)} cursor="pointer" key={document.documentId}>
<Td>
<CellTypography title={document.title} variant="omega" textColor="neutral800">
{document.title}
</CellTypography>
</Td>
<Td>
<CellTypography variant="omega" textColor="neutral600">
{document.kind === 'singleType'
? formatMessage({
id: 'content-manager.widget.last-edited.single-type',
defaultMessage: 'Single-Type',
})
: formatMessage({
id: document.contentTypeDisplayName,
defaultMessage: document.contentTypeDisplayName,
})}
</CellTypography>
</Td>
<Td>
<Box display="inline-block">
{document.status ? (
<DocumentStatus status={document.status} />
) : (
<Typography textColor="neutral600" aria-hidden>
-
</Typography>
)}
</Box>
</Td>
<Td>
<Typography textColor="neutral600">
<RelativeTime timestamp={new Date(document.updatedAt)} />
</Typography>
</Td>
<Td>
<StageColumn strapi_stage={document.strapi_stage} />
</Td>
<Td onClick={(e) => e.stopPropagation()}>
<Box display="inline-block">
<IconButton
tag={Link}
to={getEditViewLink(document)}
onClick={() => trackUsage('willEditEntryFromHome')}
label={formatMessage({
id: 'content-manager.actions.edit.label',
defaultMessage: 'Edit',
})}
variant="ghost"
>
<Pencil />
</IconButton>
</Box>
</Td>
</Tr>
))}
</Tbody>
</Table>
);
};
/* -------------------------------------------------------------------------------------------------
* AssignedWidget
* -----------------------------------------------------------------------------------------------*/
const AssignedWidget = () => {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useGetRecentlyAssignedDocumentsQuery();
if (isLoading) {
return <Widget.Loading />;
}
if (error || !data) {
return <Widget.Error />;
}
if (data.length === 0) {
return (
<Widget.NoData>
{formatMessage({
id: 'review-workflows.widget.assigned.no-data',
defaultMessage: 'No entries',
})}
</Widget.NoData>
);
}
return <RecentDocumentsTable documents={data} />;
};
export { AssignedWidget };

View File

@ -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(<AssignedWidget />);
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(<AssignedWidget />);
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(<AssignedWidget />);
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(<AssignedWidget />);
expect(screen.getByText(/No entries/i)).toBeInTheDocument();
});
});

View File

@ -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,

View File

@ -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<GetStages.Response['data']>;
meta: NonNullable<GetStages.Response['meta']>;
},
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<GetStages.Response['data']>;
meta: NonNullable<GetStages.Response['meta']>;
},
}),
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<ContentTypes, void>({
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<ContentTypes>(
(acc, curr) => {
if (curr.isDisplayed) {
acc[curr.kind].push(curr);
getContentTypes: builder.query<ContentTypes, void>({
query: () => ({
url: `/content-manager/content-types`,
method: 'GET',
}),
transformResponse: (res: { data: Contracts.ContentTypes.ContentType[] }) => {
return res.data.reduce<ContentTypes>(
(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 };

View File

@ -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"
}

View File

@ -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,
};

View File

@ -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<GetRecentlyAssignedDocuments.Response> {
return { data: await homepageService.getRecentlyAssignedDocuments() };
},
} satisfies Core.Controller;
};
export { createHomepageController };

View File

@ -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'];

View File

@ -0,0 +1,9 @@
import { routes } from './routes';
import { controllers } from './controllers';
import { services } from './services';
export default {
routes,
controllers,
services,
};

View File

@ -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 };

View File

@ -0,0 +1,6 @@
import type { Plugin } from '@strapi/types';
import { homepageRouter } from './homepage';
export const routes = {
homepage: homepageRouter,
} satisfies Plugin.LoadedPlugin['routes'];

View File

@ -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<GetRecentlyAssignedDocuments.Response['data']> {
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 };

View File

@ -0,0 +1,7 @@
import type { Plugin } from '@strapi/types';
import { createHomepageService } from './homepage';
export const services = {
homepage: createHomepageService,
} satisfies Plugin.LoadedPlugin['services'];

View File

@ -1,5 +1,7 @@
import reviewWorkflows from './review-workflows';
import homepage from '../homepage';
export default {
'review-workflows': reviewWorkflows,
...homepage.routes,
};

View File

@ -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,
};

View File

@ -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;
}
}

View File

@ -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();
});
});