Compare commits

...

7 Commits

Author SHA1 Message Date
markkaylor
ed7c7c54ff
future: add admin endpoint to get guided tour meta data (#23786) 2025-06-26 16:21:02 +02:00
Rémi de Juvigny
1aeafdcd36
chore: track widgets usage (#23809) 2025-06-26 05:24:37 -04:00
DMehaffy
e3eb76a86a
fix: remove policy from license-limit-info that breaks releases (#23424) 2025-06-25 12:22:53 -04:00
Rémi de Juvigny
339ea3d197 Merge remote-tracking branch 'origin/main' into develop 2025-06-25 17:15:43 +02:00
Rémi de Juvigny
1fedcce151
Merge pull request #23815 from strapi/releases/5.16.1
chore: 5.16.1 release
2025-06-25 11:15:12 -04:00
Yatin Gaikwad
be26954af3
fix: locale selector dropdown showing incorrect status in content man… (#23434) 2025-06-24 17:02:08 +02:00
Adrien L
1366892f87
fix: design of user status (#23771) 2025-06-24 11:41:43 +02:00
22 changed files with 420 additions and 107 deletions

View File

@ -6,9 +6,16 @@ This document explains how to create and use Guided Tours in the Strapi CMS.
## Creating tours
To create a tour use the `createTour` factory function. The function takes the name of the tour and an array of steps.
To create a tour use the `createTour` factory function. The function takes the following arguments:
The `content` key of a step is a render prop that receives `Step` and an object with `state` and `dispatch`.
- `tourName`: The name of the tour
- `steps`: An array of steps
Each `step` is an object with the following properties:
- `name`: The name of the step
- `requiredActions` (optional): An array of actions that must be completed before the step should be displayed.
- `content`: A render prop that receives `Step` and an object with `state` and `dispatch`.
`Step` has the following composable parts:
@ -24,6 +31,7 @@ const tours = {
contentManager: createTour('contentManager', [
{
name: 'TheFeatureStepName',
requiredActions: ['didDoSomethingImportant'],
content: (Step) => (
<Step.Root side="right">
<Step.Title

View File

@ -22,8 +22,9 @@ type Action =
payload: ValidTourName;
};
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
type State = {
tours: Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
tours: Tour;
};
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
@ -55,19 +56,20 @@ const UnstableGuidedTourContext = ({
// NOTE: Maybe we just import this directly instead of a prop?
tours: Tours;
}) => {
// TODO: Get local storage to init state
// Derive the tour state from the tours object
const tours = Object.keys(registeredTours).reduce(
(acc, tourName) => {
const tourLength = Object.keys(registeredTours[tourName as ValidTourName]).length;
acc[tourName as ValidTourName] = {
currentStep: 0,
length: tourLength,
isCompleted: false,
};
return acc;
},
{} as Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>
);
const tours = Object.keys(registeredTours).reduce((acc, tourName) => {
const tourLength = Object.keys(registeredTours[tourName as ValidTourName]).length;
acc[tourName as ValidTourName] = {
currentStep: 0,
length: tourLength,
isCompleted: false,
};
return acc;
}, {} as Tour);
const [state, dispatch] = React.useReducer(reducer, {
tours,
});

View File

@ -3,7 +3,8 @@ import * as React from 'react';
import { Box, Popover } from '@strapi/design-system';
import { styled } from 'styled-components';
import { useAuth } from '../../features/Auth';
import { type GetGuidedTourMeta } from '../../../../shared/contracts/admin';
import { useGetGuidedTourMetaQuery } from '../../services/admin';
import { type State, type Action, unstableUseGuidedTour, ValidTourName } from './Context';
import { Step, createStepComponents } from './Step';
@ -13,7 +14,7 @@ import { Step, createStepComponents } from './Step';
* -----------------------------------------------------------------------------------------------*/
const tours = {
contentManager: createTour('contentManager', [
TEST: createTour('TEST', [
{
name: 'Introduction',
content: (Step) => (
@ -30,6 +31,20 @@ const tours = {
</Step.Root>
),
},
{
name: 'Done',
requiredActions: ['didCreateApiToken'],
content: (Step) => (
<Step.Root align="start">
<Step.Title id="tours.contentManager.CreateEntry.title" defaultMessage="Create entry" />
<Step.Content
id="tours.contentManager.CreateEntry.content"
defaultMessage="Click this button to create an entry"
/>
<Step.Actions />
</Step.Root>
),
},
]),
} as const;
@ -55,7 +70,6 @@ export const GuidedTourOverlay = styled(Box)`
inset: 0;
background-color: rgba(50, 50, 77, 0.2);
z-index: 10;
pointer-events: none;
`;
const UnstableGuidedTourTooltip = ({
@ -63,18 +77,30 @@ const UnstableGuidedTourTooltip = ({
content,
tourName,
step,
requiredActions,
}: {
children: React.ReactNode;
content: Content;
tourName: ValidTourName;
step: number;
requiredActions?: GetGuidedTourMeta.Response['data']['completedActions'];
}) => {
const { data: guidedTourMeta } = useGetGuidedTourMetaQuery();
const state = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.state);
const dispatch = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.dispatch);
const Step = React.useMemo(() => createStepComponents(tourName), [tourName]);
const isCurrentStep = state.tours[tourName].currentStep === step;
const isPopoverOpen = isCurrentStep && !state.tours[tourName].isCompleted;
const hasCompletedRequiredActions =
requiredActions?.every((action) => {
return guidedTourMeta?.data?.completedActions.includes(action);
}) ?? true;
const isEnabled =
guidedTourMeta?.data?.isFirstSuperAdminUser && !state.tours[tourName].isCompleted;
const isPopoverOpen = isEnabled && isCurrentStep && hasCompletedRequiredActions;
// Lock the scroll
React.useEffect(() => {
@ -106,6 +132,7 @@ const UnstableGuidedTourTooltip = ({
type TourStep<P extends string> = {
name: P;
content: Content;
requiredActions?: GetGuidedTourMeta.Response['data']['completedActions'];
};
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
@ -123,6 +150,7 @@ function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: s
tourName={tourName as ValidTourName}
step={index}
content={step.content}
requiredActions={step.requiredActions}
>
{children}
</UnstableGuidedTourTooltip>

View File

@ -15,6 +15,7 @@ describe('GuidedTour | reducer', () => {
const action: Action = {
type: 'next_step',
// @ts-expect-error Remove once actual tours are in place
payload: 'contentManager',
};
@ -28,6 +29,7 @@ describe('GuidedTour | reducer', () => {
},
};
//@ts-expect-error Remove once actual tours are in place
expect(reducer(initialState, action)).toEqual(expectedState);
});
@ -49,6 +51,7 @@ describe('GuidedTour | reducer', () => {
const action: Action = {
type: 'next_step',
// @ts-expect-error Remove once actual tours are in place
payload: 'contentManager',
};
@ -67,6 +70,7 @@ describe('GuidedTour | reducer', () => {
},
};
//@ts-expect-error Remove once actual tours are in place
expect(reducer(initialState, action)).toEqual(expectedState);
});
@ -83,6 +87,7 @@ describe('GuidedTour | reducer', () => {
const action: Action = {
type: 'next_step',
// @ts-expect-error Remove once actual tours are in place
payload: 'contentManager',
};
@ -96,6 +101,7 @@ describe('GuidedTour | reducer', () => {
},
};
//@ts-expect-error Remove once actual tours are in place
expect(reducer(initialState, action)).toEqual(expectedState);
});
});
@ -114,6 +120,7 @@ describe('GuidedTour | reducer', () => {
const action: Action = {
type: 'skip_tour',
// @ts-expect-error Remove once actual tours are in place
payload: 'contentManager',
};
@ -127,6 +134,7 @@ describe('GuidedTour | reducer', () => {
},
};
//@ts-expect-error Remove once actual tours are in place
expect(reducer(initialState, action)).toEqual(expectedState);
});
@ -148,6 +156,7 @@ describe('GuidedTour | reducer', () => {
const action: Action = {
type: 'skip_tour',
// @ts-expect-error Remove once actual tours are in place
payload: 'contentManager',
};
@ -166,6 +175,7 @@ describe('GuidedTour | reducer', () => {
},
};
//@ts-expect-error Remove once actual tours are in place
expect(reducer(initialState, action)).toEqual(expectedState);
});
});

View File

@ -6,7 +6,6 @@ import { useInitQuery, useTelemetryPropertiesQuery } from '../services/admin';
import { useAppInfo } from './AppInfo';
import { useAuth } from './Auth';
import { useStrapiApp } from './StrapiApp';
export interface TelemetryProperties {
useTypescriptOnServer?: boolean;
@ -40,42 +39,12 @@ export interface TrackingProviderProps {
const TrackingProvider = ({ children }: TrackingProviderProps) => {
const token = useAuth('App', (state) => state.token);
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
const { data: initData } = useInitQuery();
const { uuid } = initData ?? {};
const { data } = useTelemetryPropertiesQuery(undefined, {
skip: !initData?.uuid || !token,
});
React.useEffect(() => {
if (uuid && data) {
const event = 'didInitializeAdministration';
try {
fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
// This event is anonymous
event,
userId: '',
eventPropeties: {},
groupProperties: {
...data,
projectId: uuid,
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
},
}),
headers: {
'Content-Type': 'application/json',
'X-Strapi-Event': event,
},
});
} catch {
// silence is golden
}
}
}, [data, uuid, getAllWidgets]);
const value = React.useMemo(
() => ({
uuid,
@ -101,7 +70,6 @@ const TrackingProvider = ({ children }: TrackingProviderProps) => {
interface EventWithoutProperties {
name:
| 'changeComponentsOrder'
| 'didAccessAuthenticatedAdministration'
| 'didAddComponentToDynamicZone'
| 'didBulkDeleteEntries'
| 'didNotBulkDeleteEntries'
@ -197,6 +165,14 @@ interface EventWithoutProperties {
properties?: never;
}
interface DidAccessAuthenticatedAdministrationEvent {
name: 'didAccessAuthenticatedAdministration';
properties: {
registeredWidgets: string[];
projectId: string;
};
}
interface DidFilterMediaLibraryElementsEvent {
name: 'didFilterMediaLibraryElements';
properties: MediaEvents['properties'] & {
@ -384,6 +360,7 @@ interface DidUpdateCTBSchema {
type EventsWithProperties =
| CreateEntryEvents
| PublishEntryEvents
| DidAccessAuthenticatedAdministrationEvent
| DidAccessTokenListEvent
| DidChangeModeEvent
| DidCropFileEvent

View File

@ -52,13 +52,13 @@ describe('useTracking', () => {
it('should call axios.post with all attributes by default when calling trackUsage()', async () => {
const { result } = setup();
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
const res = await result.current.trackUsage('didSaveContentType');
expect(axios.post).toBeCalledWith(
'https://analytics.strapi.io/api/v2/track',
{
userId: 'someTestUserId',
event: 'didAccessAuthenticatedAdministration',
event: 'didSaveContentType',
eventProperties: {},
groupProperties: {
useTypescriptOnServer: true,
@ -70,7 +70,7 @@ describe('useTracking', () => {
{
headers: {
'Content-Type': 'application/json',
'X-Strapi-Event': 'didAccessAuthenticatedAdministration',
'X-Strapi-Event': 'didSaveContentType',
},
}
);
@ -85,7 +85,7 @@ describe('useTracking', () => {
const { result } = setup();
await result.current.trackUsage('didAccessAuthenticatedAdministration');
await result.current.trackUsage('didSaveContentType');
expect(axios.post).not.toBeCalled();
@ -97,7 +97,7 @@ describe('useTracking', () => {
const { result } = setup();
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
const res = await result.current.trackUsage('didSaveContentType');
expect(axios.post).toHaveBeenCalled();
expect(res).toEqual(null);
@ -114,7 +114,7 @@ describe('useTracking', () => {
const { result } = setup();
await result.current.trackUsage('didAccessAuthenticatedAdministration');
await result.current.trackUsage('didSaveContentType');
expect(axios.post).not.toBeCalled();
});

View File

@ -20,6 +20,7 @@ import { UpsellBanner } from '../components/UpsellBanner';
import { AppInfoProvider } from '../features/AppInfo';
import { useAuth } from '../features/Auth';
import { useConfiguration } from '../features/Configuration';
import { useStrapiApp } from '../features/StrapiApp';
import { useTracking } from '../features/Tracking';
import { useMenu } from '../hooks/useMenu';
import { useOnce } from '../hooks/useOnce';
@ -93,14 +94,16 @@ const AdminLayout = () => {
pluginsSectionLinks,
} = useMenu(checkLatestStrapiVersion(strapiVersion, tagName));
/**
* Make sure the event is only send once after accessing the admin panel
* and not at runtime for example when regenerating the permissions with the ctb
* or with i18n
*/
useOnce(() => {
trackUsage('didAccessAuthenticatedAdministration');
});
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
const projectId = appInfo?.projectId;
React.useEffect(() => {
if (projectId) {
trackUsage('didAccessAuthenticatedAdministration', {
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
projectId,
});
}
}, [projectId, getAllWidgets, trackUsage]);
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions

View File

@ -288,14 +288,10 @@ const TABLE_HEADERS: Array<
cellFormatter({ isActive }) {
return (
<Flex>
<Status
size="S"
borderWidth={0}
background="transparent"
color="neutral800"
variant={isActive ? 'success' : 'danger'}
>
<Typography>{isActive ? 'Active' : 'Inactive'}</Typography>
<Status size="S" variant={isActive ? 'success' : 'danger'}>
<Typography tag="span" variant="omega" fontWeight="bold">
{isActive ? 'Active' : 'Inactive'}
</Typography>
</Status>
</Flex>
);

View File

@ -6,6 +6,7 @@ import {
type UpdateProjectSettings,
type Plugins,
type GetLicenseLimitInformation,
GetGuidedTourMeta,
} from '../../../shared/contracts/admin';
import { prefixFileUrlWithBackendUrl } from '../utils/urls';
@ -21,7 +22,7 @@ interface ConfigurationLogo {
const admin = adminApi
.enhanceEndpoints({
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft'],
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft', 'GuidedTourMeta'],
})
.injectEndpoints({
endpoints: (builder) => ({
@ -114,6 +115,13 @@ const admin = adminApi
}),
providesTags: ['LicenseTrialTimeLeft'],
}),
getGuidedTourMeta: builder.query<GetGuidedTourMeta.Response, void>({
query: () => ({
url: '/admin/guided-tour-meta',
method: 'GET',
}),
providesTags: ['GuidedTourMeta'],
}),
}),
overrideExisting: false,
});
@ -127,6 +135,7 @@ const {
useGetPluginsQuery,
useGetLicenseLimitsQuery,
useGetLicenseTrialTimeLeftQuery,
useGetGuidedTourMetaQuery,
} = admin;
export {
@ -138,6 +147,7 @@ export {
useGetPluginsQuery,
useGetLicenseLimitsQuery,
useGetLicenseTrialTimeLeftQuery,
useGetGuidedTourMetaQuery,
};
export type { ConfigurationLogo };

View File

@ -4,7 +4,7 @@ import { adminApi } from './api';
const apiTokensService = adminApi
.enhanceEndpoints({
addTagTypes: ['ApiToken'],
addTagTypes: ['ApiToken', 'GuidedTourMeta'],
})
.injectEndpoints({
endpoints: (builder) => ({
@ -31,7 +31,7 @@ const apiTokensService = adminApi
data: body,
}),
transformResponse: (response: ApiToken.Create.Response) => response.data,
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }],
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }, 'GuidedTourMeta'],
}),
deleteAPIToken: builder.mutation<
ApiToken.Revoke.Response['data'],

View File

@ -7,20 +7,7 @@ export default {
path: '/license-limit-information',
handler: 'admin.licenseLimitInformation',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: [
'admin::users.create',
'admin::users.read',
'admin::users.update',
'admin::users.delete',
],
},
},
],
policies: ['admin::isAuthenticatedAdmin'],
},
},
],

View File

@ -4,6 +4,7 @@ import { async } from '@strapi/utils';
import { getService } from './utils';
import adminActions from './config/admin-actions';
import adminConditions from './config/admin-conditions';
import constants from './services/constants';
const defaultAdminAuthSettings = {
providers: {
@ -88,21 +89,9 @@ const createDefaultAPITokensIfNeeded = async () => {
const apiTokenCount = await apiTokenService.count();
if (usersCount === 0 && apiTokenCount === 0) {
await apiTokenService.create({
name: 'Read Only',
description:
'A default API token with read-only permissions, only used for accessing resources',
type: 'read-only',
lifespan: null,
});
await apiTokenService.create({
name: 'Full Access',
description:
'A default API token with full access permissions, used for accessing or modifying resources',
type: 'full-access',
lifespan: null,
});
for (const token of constants.DEFAULT_API_TOKENS) {
await apiTokenService.create(token);
}
}
};

View File

@ -5,7 +5,6 @@ import path from 'path';
import { map, values, sumBy, pipe, flatMap, propEq } from 'lodash/fp';
import _ from 'lodash';
import { exists } from 'fs-extra';
import '@strapi/types';
import { env } from '@strapi/utils';
import tsUtils from '@strapi/typescript-utils';
import {
@ -22,6 +21,7 @@ import type {
Plugins,
TelemetryProperties,
UpdateProjectSettings,
GetGuidedTourMeta,
} from '../../../shared/contracts/admin';
const { isUsingTypeScript } = tsUtils;
@ -185,4 +185,18 @@ export default {
return data;
},
async getGuidedTourMeta(ctx: Context) {
const [isFirstSuperAdminUser, completedActions] = await Promise.all([
getService('user').isFirstSuperAdminUser(ctx.state.user.id),
getService('guided-tour').getCompletedActions(),
]);
return {
data: {
isFirstSuperAdminUser,
completedActions,
},
} satisfies GetGuidedTourMeta.Response;
},
};

View File

@ -74,4 +74,12 @@ export default [
policies: ['admin::isAuthenticatedAdmin'],
},
},
{
method: 'GET',
path: '/guided-tour-meta',
handler: 'admin.getGuidedTourMeta',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
];

View File

@ -22,6 +22,22 @@ const constants = {
DAYS_30: 30 * DAY_IN_MS,
DAYS_90: 90 * DAY_IN_MS,
},
DEFAULT_API_TOKENS: [
{
name: 'Read Only',
description:
'A default API token with read-only permissions, only used for accessing resources',
type: 'read-only',
lifespan: null,
},
{
name: 'Full Access',
description:
'A default API token with full access permissions, used for accessing or modifying resources',
type: 'full-access',
lifespan: null,
},
] as const,
TRANSFER_TOKEN_TYPE: {
PUSH: 'push',
PULL: 'pull',

View File

@ -0,0 +1,56 @@
import { Core, Internal } from '@strapi/types';
import constants from './constants';
export type GuidedTourRequiredActions = {
didCreateContentTypeSchema: boolean;
didCreateContent: boolean;
didCreateApiToken: boolean;
};
export type GuidedTourCompletedActions = keyof GuidedTourRequiredActions;
export const createGuidedTourService = ({ strapi }: { strapi: Core.Strapi }) => {
const getCompletedActions = async () => {
// Check if any content-type schemas have been create on the api:: namespace
const contentTypeSchemaNames = Object.keys(strapi.contentTypes).filter((contentTypeUid) =>
contentTypeUid.startsWith('api::')
);
const didCreateContentTypeSchema = contentTypeSchemaNames.length > 0;
// Check if any content has been created for content-types on the api:: namespace
const hasContent = await (async () => {
for (const name of contentTypeSchemaNames) {
const count = await strapi.documents(name as Internal.UID.ContentType).count({});
if (count > 0) return true;
}
return false;
})();
const didCreateContent = didCreateContentTypeSchema && hasContent;
// Check if any api tokens have been created besides the default ones
const createdApiTokens = await strapi
.documents('admin::api-token')
.findMany({ fields: ['name', 'description'] });
const didCreateApiToken = createdApiTokens.some((doc) =>
constants.DEFAULT_API_TOKENS.every(
(token) => token.name !== doc.name && token.description !== doc.description
)
);
// Compute an array of action names that have been completed
const requiredActions = {
didCreateContentTypeSchema,
didCreateContent,
didCreateApiToken,
};
const requiredActionNames = Object.keys(requiredActions) as Array<GuidedTourCompletedActions>;
const completedActions = requiredActionNames.filter((key) => requiredActions[key]);
return completedActions;
};
return {
getCompletedActions,
};
};

View File

@ -14,6 +14,7 @@ import * as action from './action';
import * as apiToken from './api-token';
import * as transfer from './transfer';
import * as projectSettings from './project-settings';
import { createGuidedTourService } from './guided-tour';
// TODO: TS - Export services one by one as this export is cjs
export default {
@ -32,4 +33,5 @@ export default {
transfer,
'project-settings': projectSettings,
encryption,
'guided-tour': createGuidedTourService,
};

View File

@ -161,6 +161,31 @@ const isLastSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
return superAdminRole.usersCount === 1 && hasSuperAdminRole(user);
};
/**
* Check if a user is the first super admin
* @param userId user's id to look for
*/
const isFirstSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
const currentUser = (await findOne(userId)) as AdminUser | null;
if (!currentUser || !hasSuperAdminRole(currentUser)) return false;
const [oldestUser] = await strapi.db.query('admin::user').findMany({
populate: {
roles: {
where: {
code: { $eq: SUPER_ADMIN_CODE },
},
},
},
orderBy: { createdAt: 'asc' },
limit: 1,
select: ['id'],
});
return oldestUser.id === currentUser.id;
};
/**
* Check if a user with specific attributes exists in the database
* @param attributes A partial user object
@ -390,4 +415,5 @@ export default {
displayWarningIfUsersDontHaveRole,
resetPasswordByEmail,
getLanguagesInUse,
isFirstSuperAdminUser,
};

View File

@ -10,6 +10,7 @@ import * as token from '../services/token';
import * as apiToken from '../services/api-token';
import * as projectSettings from '../services/project-settings';
import * as transfer from '../services/transfer';
import { createGuidedTourService } from '../services/guided-tour';
type S = {
role: typeof role;
@ -24,6 +25,7 @@ type S = {
'project-settings': typeof projectSettings;
transfer: typeof transfer;
encryption: typeof encryption;
'guided-tour': ReturnType<typeof createGuidedTourService>;
};
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };

View File

@ -1,5 +1,7 @@
import { Data } from '@strapi/types';
import { errors } from '@strapi/utils';
import type { File } from 'formidable';
import type { GuidedTourCompletedActions } from '../../server/src/services/guided-tour';
export interface Logo {
name: string;
@ -218,3 +220,18 @@ export declare namespace GetLicenseLimitInformation {
error?: errors.ApplicationError;
}
}
/**
* Meta data for the guided tour
*/
export declare namespace GetGuidedTourMeta {
export interface Request {}
export interface Response {
data: {
isFirstSuperAdminUser: boolean;
completedActions: GuidedTourCompletedActions[];
};
error?: errors.ApplicationError;
}
}

View File

@ -285,7 +285,9 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
const otherStatus = await this.getManyAvailableStatus(uid, document.localizations);
document.localizations = document.localizations.map((d) => {
const status = otherStatus.find((s) => s.documentId === d.documentId);
const status = otherStatus.find(
(s) => s.documentId === d.documentId && s.locale === d.locale
);
return {
...d,
status: this.getStatus(d, status ? [status] : []),

View File

@ -0,0 +1,160 @@
import { createStrapiInstance } from 'api-tests/strapi';
import { createAuthRequest } from 'api-tests/request';
import { createTestBuilder } from 'api-tests/builder';
import { Core } from '@strapi/types';
const articleContentType = {
displayName: 'article',
singularName: 'article',
pluralName: 'articles',
kind: 'collectionType',
attributes: {
name: {
type: 'string',
},
},
};
let authRq;
let strapi: Core.Strapi;
const builder = createTestBuilder();
const restartWithSchema = async () => {
await strapi.destroy();
await builder.cleanup();
await builder.addContentType(articleContentType).build();
strapi = await createStrapiInstance();
authRq = await createAuthRequest({ strapi });
};
describe('Guided Tour Meta', () => {
beforeAll(async () => {
strapi = await createStrapiInstance();
authRq = await createAuthRequest({ strapi });
});
afterEach(async () => {
// Ensure each test cleans up
await restartWithSchema();
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
describe('GET /admin/guided-tour-meta', () => {
/**
* TODO:
* clean-after-delete.test.api.ts leaks data causing the app
* to intialize withe a schema and content. We need to ensure that test cleans up after itself
* Skipping for now.
*/
test.skip('Returns correct initial state for a new installation', async () => {
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
isFirstSuperAdminUser: true,
completedActions: [],
});
});
test('Detects first super admin user', async () => {
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(res.status).toBe(200);
expect(res.body.data.isFirstSuperAdminUser).toBe(true);
const newUser = {
email: 'second@user.com',
firstname: 'second',
lastname: 'user',
password: 'second123',
roles: [1],
isActive: true,
};
await strapi.db.query('admin::user').create({ data: newUser });
const request = await createAuthRequest({
strapi,
userInfo: newUser,
});
const secondSuperAdminUserResponse = await request({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(secondSuperAdminUserResponse.status).toBe(200);
expect(secondSuperAdminUserResponse.body.data.isFirstSuperAdminUser).toBe(false);
});
test('Detects created content type schemas', async () => {
await restartWithSchema();
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(res.status).toBe(200);
expect(res.body.data.completedActions).toContain('didCreateContentTypeSchema');
});
test('Detects created content', async () => {
await restartWithSchema();
const createdDocument = await strapi.documents('api::article.article').create({
data: {
name: 'Article 1',
},
});
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(res.status).toBe(200);
expect(res.body.data.completedActions).toContain('didCreateContent');
// Cleanup
await strapi.documents('api::article.article').delete({
documentId: createdDocument.documentId,
});
});
test('Detects created custom API tokens', async () => {
// Create a custom API token
const createdToken = await strapi.documents('admin::api-token').create({
data: {
name: 'Custom Token',
type: 'read-only',
description: 'Test token',
accessKey: 'beep boop',
},
});
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
});
expect(res.status).toBe(200);
expect(res.body.data.completedActions).toContain('didCreateApiToken');
// Cleanup
await strapi.documents('admin::api-token').delete({
documentId: createdToken.documentId,
});
});
});
});