fix(admin): reset all redux toolkit cache on logout (#20318)

* fix(admin): reset all redux toolkit cache on logout

* chore: refactor API to use one createApi call from redux/toolkit

* chore: fix e2e suite

* chore: spelling error

Co-authored-by: Bassel Kanso <basselkanso82@gmail.com>

* chore: remove rogue import

---------

Co-authored-by: Bassel Kanso <basselkanso82@gmail.com>
This commit is contained in:
Josh 2024-05-20 14:43:30 +01:00 committed by GitHub
parent 5ff7024fc8
commit e98c3e2020
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1042 additions and 1684 deletions

View File

@ -7,6 +7,7 @@ import { createContext } from '../components/Context';
import { useTypedDispatch } from '../core/store/hooks';
import { useStrapiApp } from '../features/StrapiApp';
import { setLocale } from '../reducer';
import { adminApi } from '../services/api';
import {
useGetMeQuery,
useGetMyPermissionsQuery,
@ -189,9 +190,10 @@ const AuthProvider = ({ children, _defaultPermissions = [] }: AuthProviderProps)
const logout = React.useCallback(async () => {
await logoutMutation();
dispatch(adminApi.util.resetApiState());
clearStorage();
navigate('/auth/login');
}, [clearStorage, logoutMutation, navigate]);
}, [clearStorage, dispatch, logoutMutation, navigate]);
const refetchPermissions = React.useCallback(async () => {
if (!isUninitialized) {

View File

@ -14,7 +14,7 @@ export function useContentTypes(): {
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
const { toggleNotification } = useNotification();
const { data = [], isLoading, error } = useGetContentTypesQuery();
const { data, isLoading, error } = useGetContentTypesQuery();
React.useEffect(() => {
if (error) {
@ -25,25 +25,9 @@ export function useContentTypes(): {
}
}, [error, formatAPIError, toggleNotification]);
// the return value needs to be memoized, because intantiating
// an empty array as default value would lead to an unstable return
// value, which later on triggers infinite loops if used in the
// dependency arrays of other hooks
const collectionTypes = React.useMemo(() => {
return data.filter(
(contentType) => contentType.kind === 'collectionType' && contentType.isDisplayed
);
}, [data]);
const singleTypes = React.useMemo(() => {
return data.filter(
(contentType) => contentType.kind !== 'collectionType' && contentType.isDisplayed
);
}, [data]);
return {
isLoading,
collectionTypes,
singleTypes,
collectionTypes: data?.collectionType ?? [],
singleTypes: data?.singleType ?? [],
};
}

View File

@ -52,7 +52,6 @@ export { useAdminUsers } from './services/users';
export type { StrapiApp, MenuItem, InjectionZoneComponent } from './StrapiApp';
export type { Store } from './core/store/configure';
export type { Plugin, PluginConfig } from './core/apis/Plugin';
export type { FetchOptions, FetchResponse, FetchConfig } from './utils/getFetchClient';
export type {
SanitizedAdminUser,
AdminUser,
@ -66,7 +65,9 @@ export type { RBACContext, RBACMiddleware } from './core/apis/rbac';
* Utils
*/
export { translatedErrors } from './utils/translatedErrors';
export { getFetchClient, isFetchError, FetchError } from './utils/getFetchClient';
export * from './utils/getFetchClient';
export * from './utils/baseQuery';
export * from './services/api';
/**
* Components

View File

@ -9,7 +9,7 @@ import { Layouts } from '../../../../components/Layouts/Layout';
import { BackButton } from '../../../../features/BackButton';
import { useNotification } from '../../../../features/Notifications';
import { useAPIErrorHandler } from '../../../../hooks/useAPIErrorHandler';
import { useRegenerateTokenMutation } from '../../../../services/api';
import { useRegenerateTokenMutation } from '../../../../services/transferTokens';
import type { Data } from '@strapi/types';

View File

@ -19,93 +19,97 @@ interface ConfigurationLogo {
default: string;
}
const admin = adminApi.injectEndpoints({
endpoints: (builder) => ({
init: builder.query<Init.Response['data'], void>({
query: () => ({
url: '/admin/init',
method: 'GET',
}),
transformResponse(res: Init.Response) {
return res.data;
},
}),
information: builder.query<Information.Response['data'], void>({
query: () => ({
url: '/admin/information',
method: 'GET',
}),
transformResponse(res: Information.Response) {
return res.data;
},
}),
telemetryProperties: builder.query<TelemetryProperties.Response['data'], void>({
query: () => ({
url: '/admin/telemetry-properties',
method: 'GET',
config: {
validateStatus: (status) => status < 500,
const admin = adminApi
.enhanceEndpoints({
addTagTypes: ['ProjectSettings', 'LicenseLimits'],
})
.injectEndpoints({
endpoints: (builder) => ({
init: builder.query<Init.Response['data'], void>({
query: () => ({
url: '/admin/init',
method: 'GET',
}),
transformResponse(res: Init.Response) {
return res.data;
},
}),
transformResponse(res: TelemetryProperties.Response) {
return res.data;
},
}),
projectSettings: builder.query<
{ authLogo?: ConfigurationLogo['custom']; menuLogo?: ConfigurationLogo['custom'] },
void
>({
query: () => ({
url: '/admin/project-settings',
method: 'GET',
information: builder.query<Information.Response['data'], void>({
query: () => ({
url: '/admin/information',
method: 'GET',
}),
transformResponse(res: Information.Response) {
return res.data;
},
}),
providesTags: ['ProjectSettings'],
transformResponse(data: GetProjectSettings.Response) {
return {
authLogo: data.authLogo
? {
name: data.authLogo.name,
url: prefixFileUrlWithBackendUrl(data.authLogo.url),
}
: undefined,
menuLogo: data.menuLogo
? {
name: data.menuLogo.name,
url: prefixFileUrlWithBackendUrl(data.menuLogo.url),
}
: undefined,
};
},
}),
updateProjectSettings: builder.mutation<UpdateProjectSettings.Response, FormData>({
query: (data) => ({
url: '/admin/project-settings',
method: 'POST',
data,
config: {
headers: {
'Content-Type': 'multipart/form-data',
telemetryProperties: builder.query<TelemetryProperties.Response['data'], void>({
query: () => ({
url: '/admin/telemetry-properties',
method: 'GET',
config: {
validateStatus: (status) => status < 500,
},
}),
transformResponse(res: TelemetryProperties.Response) {
return res.data;
},
}),
invalidatesTags: ['ProjectSettings'],
}),
getPlugins: builder.query<Plugins.Response, void>({
query: () => ({
url: '/admin/plugins',
method: 'GET',
projectSettings: builder.query<
{ authLogo?: ConfigurationLogo['custom']; menuLogo?: ConfigurationLogo['custom'] },
void
>({
query: () => ({
url: '/admin/project-settings',
method: 'GET',
}),
providesTags: ['ProjectSettings'],
transformResponse(data: GetProjectSettings.Response) {
return {
authLogo: data.authLogo
? {
name: data.authLogo.name,
url: prefixFileUrlWithBackendUrl(data.authLogo.url),
}
: undefined,
menuLogo: data.menuLogo
? {
name: data.menuLogo.name,
url: prefixFileUrlWithBackendUrl(data.menuLogo.url),
}
: undefined,
};
},
}),
updateProjectSettings: builder.mutation<UpdateProjectSettings.Response, FormData>({
query: (data) => ({
url: '/admin/project-settings',
method: 'POST',
data,
config: {
headers: {
'Content-Type': 'multipart/form-data',
},
},
}),
invalidatesTags: ['ProjectSettings'],
}),
getPlugins: builder.query<Plugins.Response, void>({
query: () => ({
url: '/admin/plugins',
method: 'GET',
}),
}),
getLicenseLimits: builder.query<GetLicenseLimitInformation.Response, void>({
query: () => ({
url: '/admin/license-limit-information',
method: 'GET',
}),
providesTags: ['LicenseLimits'],
}),
}),
getLicenseLimits: builder.query<GetLicenseLimitInformation.Response, void>({
query: () => ({
url: '/admin/license-limit-information',
method: 'GET',
}),
providesTags: ['LicenseLimits'],
}),
}),
overrideExisting: false,
});
overrideExisting: false,
});
const {
useInitQuery,

View File

@ -1,37 +1,21 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { TokenRegenerate } from '../../../shared/contracts/transfer';
import { fetchBaseQuery, type UnknownApiError } from '../utils/baseQuery';
import { fetchBaseQuery } from '../utils/baseQuery';
/**
* @public
* @description This is the redux toolkit api for the admin panel, users
* should use a combination of `enhanceEndpoints` to add their TagTypes
* to utilise in their `injectEndpoints` construction for automatic cache
* re-validation. We specifically do not store any tagTypes by default leaving
* the API surface as small as possible. None of the data-fetching looks for the
* StrapiApp are stored here either.
*/
const adminApi = createApi({
reducerPath: 'adminApi',
baseQuery: fetchBaseQuery(),
tagTypes: [
'ApiToken',
'LicenseLimits',
'Me',
'ProjectSettings',
'ProvidersOptions',
'Role',
'RolePermissions',
'TransferToken',
'User',
'Webhook',
],
endpoints: (builder) => ({
/**
* This is here because it's shared between the transfer-token routes & the api-tokens.
*/
regenerateToken: builder.mutation<TokenRegenerate.Response['data'], string>({
query: (url) => ({
method: 'POST',
url: `${url}/regenerate`,
}),
transformResponse: (response: TokenRegenerate.Response) => response.data,
}),
}),
tagTypes: [],
endpoints: () => ({}),
});
const { useRegenerateTokenMutation } = adminApi;
export { adminApi, type UnknownApiError, useRegenerateTokenMutation };
export { adminApi };

View File

@ -2,59 +2,62 @@ import * as ApiToken from '../../../shared/contracts/api-token';
import { adminApi } from './api';
const transferTokenService = adminApi.injectEndpoints({
endpoints: (builder) => ({
getAPITokens: builder.query<ApiToken.List.Response['data'], void>({
query: () => '/admin/api-tokens',
transformResponse: (response: ApiToken.List.Response) => response.data,
providesTags: (res, _err) => [
...(res?.map(({ id }) => ({ type: 'ApiToken' as const, id })) ?? []),
{ type: 'ApiToken' as const, id: 'LIST' },
],
}),
getAPIToken: builder.query<ApiToken.Get.Response['data'], ApiToken.Get.Params['id']>({
query: (id) => `/admin/api-tokens/${id}`,
transformResponse: (response: ApiToken.Get.Response) => response.data,
providesTags: (res, _err, id) => [{ type: 'ApiToken' as const, id }],
}),
createAPIToken: builder.mutation<
ApiToken.Create.Response['data'],
ApiToken.Create.Request['body']
>({
query: (body) => ({
url: '/admin/api-tokens',
method: 'POST',
data: body,
const apiTokensService = adminApi
.enhanceEndpoints({
addTagTypes: ['ApiToken'],
})
.injectEndpoints({
endpoints: (builder) => ({
getAPITokens: builder.query<ApiToken.List.Response['data'], void>({
query: () => '/admin/api-tokens',
transformResponse: (response: ApiToken.List.Response) => response.data,
providesTags: (res, _err) => [
...(res?.map(({ id }) => ({ type: 'ApiToken' as const, id })) ?? []),
{ type: 'ApiToken' as const, id: 'LIST' },
],
}),
transformResponse: (response: ApiToken.Create.Response) => response.data,
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }],
}),
deleteAPIToken: builder.mutation<
ApiToken.Revoke.Response['data'],
ApiToken.Revoke.Params['id']
>({
query: (id) => ({
url: `/admin/api-tokens/${id}`,
method: 'DELETE',
getAPIToken: builder.query<ApiToken.Get.Response['data'], ApiToken.Get.Params['id']>({
query: (id) => `/admin/api-tokens/${id}`,
transformResponse: (response: ApiToken.Get.Response) => response.data,
providesTags: (res, _err, id) => [{ type: 'ApiToken' as const, id }],
}),
transformResponse: (response: ApiToken.Revoke.Response) => response.data,
invalidatesTags: (_res, _err, id) => [{ type: 'ApiToken' as const, id }],
}),
updateAPIToken: builder.mutation<
ApiToken.Update.Response['data'],
ApiToken.Update.Params & ApiToken.Update.Request['body']
>({
query: ({ id, ...body }) => ({
url: `/admin/api-tokens/${id}`,
method: 'PUT',
data: body,
createAPIToken: builder.mutation<
ApiToken.Create.Response['data'],
ApiToken.Create.Request['body']
>({
query: (body) => ({
url: '/admin/api-tokens',
method: 'POST',
data: body,
}),
transformResponse: (response: ApiToken.Create.Response) => response.data,
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }],
}),
deleteAPIToken: builder.mutation<
ApiToken.Revoke.Response['data'],
ApiToken.Revoke.Params['id']
>({
query: (id) => ({
url: `/admin/api-tokens/${id}`,
method: 'DELETE',
}),
transformResponse: (response: ApiToken.Revoke.Response) => response.data,
invalidatesTags: (_res, _err, id) => [{ type: 'ApiToken' as const, id }],
}),
updateAPIToken: builder.mutation<
ApiToken.Update.Response['data'],
ApiToken.Update.Params & ApiToken.Update.Request['body']
>({
query: ({ id, ...body }) => ({
url: `/admin/api-tokens/${id}`,
method: 'PUT',
data: body,
}),
transformResponse: (response: ApiToken.Update.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'ApiToken' as const, id }],
}),
transformResponse: (response: ApiToken.Update.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'ApiToken' as const, id }],
}),
}),
overrideExisting: false,
});
});
const {
useGetAPITokensQuery,
@ -62,7 +65,7 @@ const {
useCreateAPITokenMutation,
useDeleteAPITokenMutation,
useUpdateAPITokenMutation,
} = transferTokenService;
} = apiTokensService;
export {
useGetAPITokensQuery,

View File

@ -14,177 +14,187 @@ import { type GetOwnPermissions, type GetMe, type UpdateMe } from '../../../shar
import { adminApi } from './api';
const authService = adminApi.injectEndpoints({
endpoints: (builder) => ({
/**
* ME
*/
getMe: builder.query<GetMe.Response['data'], void>({
query: () => ({
method: 'GET',
url: '/admin/users/me',
const authService = adminApi
.enhanceEndpoints({
addTagTypes: ['User', 'Me', 'ProvidersOptions'],
})
.injectEndpoints({
endpoints: (builder) => ({
/**
* ME
*/
getMe: builder.query<GetMe.Response['data'], void>({
query: () => ({
method: 'GET',
url: '/admin/users/me',
}),
transformResponse(res: GetMe.Response) {
return res.data;
},
providesTags: (res) => (res ? ['Me', { type: 'User', id: res.id }] : ['Me']),
}),
transformResponse(res: GetMe.Response) {
return res.data;
},
providesTags: (res) => (res ? ['Me', { type: 'User', id: res.id }] : ['Me']),
}),
getMyPermissions: builder.query<GetOwnPermissions.Response['data'], void>({
query: () => ({
method: 'GET',
url: '/admin/users/me/permissions',
}),
transformResponse(res: GetOwnPermissions.Response) {
return res.data;
},
}),
updateMe: builder.mutation<UpdateMe.Response['data'], UpdateMe.Request['body']>({
query: (body) => ({
method: 'PUT',
url: '/admin/users/me',
data: body,
}),
transformResponse(res: UpdateMe.Response) {
return res.data;
},
invalidatesTags: ['Me'],
}),
/**
* Permissions
*/
checkPermissions: builder.query<Check.Response, Check.Request['body']>({
query: (permissions) => ({
method: 'POST',
url: '/admin/permissions/check',
data: permissions,
}),
}),
/**
* Auth methods
*/
login: builder.mutation<Login.Response['data'], Login.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/login',
data: body,
}),
transformResponse(res: Login.Response) {
return res.data;
},
invalidatesTags: ['Me'],
}),
logout: builder.mutation<void, void>({
query: () => ({
method: 'POST',
url: '/admin/logout',
}),
}),
resetPassword: builder.mutation<ResetPassword.Response['data'], ResetPassword.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/reset-password',
data: body,
}),
transformResponse(res: ResetPassword.Response) {
return res.data;
},
}),
renewToken: builder.mutation<RenewToken.Response['data'], RenewToken.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/renew-token',
data: body,
}),
transformResponse(res: RenewToken.Response) {
return res.data;
},
}),
getRegistrationInfo: builder.query<
RegistrationInfo.Response['data'],
RegistrationInfo.Request['query']['registrationToken']
>({
query: (registrationToken) => ({
url: '/admin/registration-info',
method: 'GET',
config: {
params: {
registrationToken,
},
getMyPermissions: builder.query<GetOwnPermissions.Response['data'], void>({
query: () => ({
method: 'GET',
url: '/admin/users/me/permissions',
}),
transformResponse(res: GetOwnPermissions.Response) {
return res.data;
},
}),
transformResponse(res: RegistrationInfo.Response) {
return res.data;
},
}),
registerAdmin: builder.mutation<RegisterAdmin.Response['data'], RegisterAdmin.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/register-admin',
data: body,
updateMe: builder.mutation<UpdateMe.Response['data'], UpdateMe.Request['body']>({
query: (body) => ({
method: 'PUT',
url: '/admin/users/me',
data: body,
}),
transformResponse(res: UpdateMe.Response) {
return res.data;
},
invalidatesTags: ['Me'],
}),
transformResponse(res: RegisterAdmin.Response) {
return res.data;
},
}),
registerUser: builder.mutation<Register.Response['data'], Register.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/register',
data: body,
/**
* Permissions
*/
checkPermissions: builder.query<Check.Response, Check.Request['body']>({
query: (permissions) => ({
method: 'POST',
url: '/admin/permissions/check',
data: permissions,
}),
}),
transformResponse(res: Register.Response) {
return res.data;
},
}),
forgotPassword: builder.mutation<ForgotPassword.Response, ForgotPassword.Request['body']>({
query: (body) => ({
url: '/admin/forgot-password',
method: 'POST',
data: body,
/**
* Auth methods
*/
login: builder.mutation<Login.Response['data'], Login.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/login',
data: body,
}),
transformResponse(res: Login.Response) {
return res.data;
},
invalidatesTags: ['Me'],
}),
logout: builder.mutation<void, void>({
query: () => ({
method: 'POST',
url: '/admin/logout',
}),
}),
resetPassword: builder.mutation<
ResetPassword.Response['data'],
ResetPassword.Request['body']
>({
query: (body) => ({
method: 'POST',
url: '/admin/reset-password',
data: body,
}),
transformResponse(res: ResetPassword.Response) {
return res.data;
},
}),
renewToken: builder.mutation<RenewToken.Response['data'], RenewToken.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/renew-token',
data: body,
}),
transformResponse(res: RenewToken.Response) {
return res.data;
},
}),
getRegistrationInfo: builder.query<
RegistrationInfo.Response['data'],
RegistrationInfo.Request['query']['registrationToken']
>({
query: (registrationToken) => ({
url: '/admin/registration-info',
method: 'GET',
config: {
params: {
registrationToken,
},
},
}),
transformResponse(res: RegistrationInfo.Response) {
return res.data;
},
}),
registerAdmin: builder.mutation<
RegisterAdmin.Response['data'],
RegisterAdmin.Request['body']
>({
query: (body) => ({
method: 'POST',
url: '/admin/register-admin',
data: body,
}),
transformResponse(res: RegisterAdmin.Response) {
return res.data;
},
}),
registerUser: builder.mutation<Register.Response['data'], Register.Request['body']>({
query: (body) => ({
method: 'POST',
url: '/admin/register',
data: body,
}),
transformResponse(res: Register.Response) {
return res.data;
},
}),
forgotPassword: builder.mutation<ForgotPassword.Response, ForgotPassword.Request['body']>({
query: (body) => ({
url: '/admin/forgot-password',
method: 'POST',
data: body,
}),
}),
isSSOLocked: builder.query<IsSSOLocked.Response['data'], void>({
query: () => ({
url: '/admin/providers/isSSOLocked',
method: 'GET',
}),
transformResponse(res: IsSSOLocked.Response) {
return res.data;
},
}),
getProviders: builder.query<GetProviders.Response, void>({
query: () => ({
url: '/admin/providers',
method: 'GET',
}),
}),
getProviderOptions: builder.query<ProvidersOptions.Response['data'], void>({
query: () => ({
url: '/admin/providers/options',
method: 'GET',
}),
transformResponse(res: ProvidersOptions.Response) {
return res.data;
},
providesTags: ['ProvidersOptions'],
}),
updateProviderOptions: builder.mutation<
ProvidersOptions.Response['data'],
ProvidersOptions.Request['body']
>({
query: (body) => ({
url: '/admin/providers/options',
method: 'PUT',
data: body,
}),
transformResponse(res: ProvidersOptions.Response) {
return res.data;
},
invalidatesTags: ['ProvidersOptions'],
}),
}),
isSSOLocked: builder.query<IsSSOLocked.Response['data'], void>({
query: () => ({
url: '/admin/providers/isSSOLocked',
method: 'GET',
}),
transformResponse(res: IsSSOLocked.Response) {
return res.data;
},
}),
getProviders: builder.query<GetProviders.Response, void>({
query: () => ({
url: '/admin/providers',
method: 'GET',
}),
}),
getProviderOptions: builder.query<ProvidersOptions.Response['data'], void>({
query: () => ({
url: '/admin/providers/options',
method: 'GET',
}),
transformResponse(res: ProvidersOptions.Response) {
return res.data;
},
providesTags: ['ProvidersOptions'],
}),
updateProviderOptions: builder.mutation<
ProvidersOptions.Response['data'],
ProvidersOptions.Request['body']
>({
query: (body) => ({
url: '/admin/providers/options',
method: 'PUT',
data: body,
}),
transformResponse(res: ProvidersOptions.Response) {
return res.data;
},
invalidatesTags: ['ProvidersOptions'],
}),
}),
overrideExisting: false,
});
overrideExisting: false,
});
const {
useCheckPermissionsQuery,

View File

@ -6,21 +6,38 @@
import { adminApi } from './api';
import type { ContentType } from '../../../shared/contracts/content-types';
interface ContentTypes {
collectionType: ContentType[];
singleType: ContentType[];
}
const contentManager = adminApi.injectEndpoints({
endpoints: (builder) => ({
/**
* Content Types
*/
getContentTypes: builder.query<ContentType[], void>({
getContentTypes: builder.query<ContentTypes, void>({
query: () => ({
url: `/content-manager/content-types`,
method: 'GET',
}),
transformResponse: (res: { data: ContentType[] }) => res.data,
transformResponse: (res: { data: ContentType[] }) => {
return res.data.reduce<ContentTypes>(
(acc, curr) => {
if (curr.isDisplayed) {
acc[curr.kind].push(curr);
}
return acc;
},
{
collectionType: [],
singleType: [],
}
);
},
}),
}),
overrideExisting: false,
overrideExisting: true,
});
const { useGetContentTypesQuery } = contentManager;

View File

@ -2,65 +2,76 @@ import * as TransferTokens from '../../../shared/contracts/transfer';
import { adminApi } from './api';
const transferTokenService = adminApi.injectEndpoints({
endpoints: (builder) => ({
getTransferTokens: builder.query<TransferTokens.TokenList.Response['data'], void>({
query: () => ({
url: '/admin/transfer/tokens',
method: 'GET',
const transferTokenService = adminApi
.enhanceEndpoints({
addTagTypes: ['TransferToken'],
})
.injectEndpoints({
endpoints: (builder) => ({
regenerateToken: builder.mutation<TransferTokens.TokenRegenerate.Response['data'], string>({
query: (url) => ({
method: 'POST',
url: `${url}/regenerate`,
}),
transformResponse: (response: TransferTokens.TokenRegenerate.Response) => response.data,
}),
transformResponse: (response: TransferTokens.TokenList.Response) => response.data,
providesTags: (res, _err) => [
...(res?.map(({ id }) => ({ type: 'TransferToken' as const, id })) ?? []),
{ type: 'TransferToken' as const, id: 'LIST' },
],
}),
getTransferToken: builder.query<
TransferTokens.TokenGetById.Response['data'],
TransferTokens.TokenGetById.Params['id']
>({
query: (id) => `/admin/transfer/tokens/${id}`,
transformResponse: (response: TransferTokens.TokenGetById.Response) => response.data,
providesTags: (res, _err, id) => [{ type: 'TransferToken' as const, id }],
}),
createTransferToken: builder.mutation<
TransferTokens.TokenCreate.Response['data'],
TransferTokens.TokenCreate.Request['body']
>({
query: (body) => ({
url: '/admin/transfer/tokens',
method: 'POST',
data: body,
getTransferTokens: builder.query<TransferTokens.TokenList.Response['data'], void>({
query: () => ({
url: '/admin/transfer/tokens',
method: 'GET',
}),
transformResponse: (response: TransferTokens.TokenList.Response) => response.data,
providesTags: (res, _err) => [
...(res?.map(({ id }) => ({ type: 'TransferToken' as const, id })) ?? []),
{ type: 'TransferToken' as const, id: 'LIST' },
],
}),
transformResponse: (response: TransferTokens.TokenCreate.Response) => response.data,
invalidatesTags: [{ type: 'TransferToken' as const, id: 'LIST' }],
}),
deleteTransferToken: builder.mutation<
TransferTokens.TokenRevoke.Response['data'],
TransferTokens.TokenRevoke.Params['id']
>({
query: (id) => ({
url: `/admin/transfer/tokens/${id}`,
method: 'DELETE',
getTransferToken: builder.query<
TransferTokens.TokenGetById.Response['data'],
TransferTokens.TokenGetById.Params['id']
>({
query: (id) => `/admin/transfer/tokens/${id}`,
transformResponse: (response: TransferTokens.TokenGetById.Response) => response.data,
providesTags: (res, _err, id) => [{ type: 'TransferToken' as const, id }],
}),
transformResponse: (response: TransferTokens.TokenRevoke.Response) => response.data,
invalidatesTags: (_res, _err, id) => [{ type: 'TransferToken' as const, id }],
}),
updateTransferToken: builder.mutation<
TransferTokens.TokenUpdate.Response['data'],
TransferTokens.TokenUpdate.Params & TransferTokens.TokenUpdate.Request['body']
>({
query: ({ id, ...body }) => ({
url: `/admin/transfer/tokens/${id}`,
method: 'PUT',
data: body,
createTransferToken: builder.mutation<
TransferTokens.TokenCreate.Response['data'],
TransferTokens.TokenCreate.Request['body']
>({
query: (body) => ({
url: '/admin/transfer/tokens',
method: 'POST',
data: body,
}),
transformResponse: (response: TransferTokens.TokenCreate.Response) => response.data,
invalidatesTags: [{ type: 'TransferToken' as const, id: 'LIST' }],
}),
deleteTransferToken: builder.mutation<
TransferTokens.TokenRevoke.Response['data'],
TransferTokens.TokenRevoke.Params['id']
>({
query: (id) => ({
url: `/admin/transfer/tokens/${id}`,
method: 'DELETE',
}),
transformResponse: (response: TransferTokens.TokenRevoke.Response) => response.data,
invalidatesTags: (_res, _err, id) => [{ type: 'TransferToken' as const, id }],
}),
updateTransferToken: builder.mutation<
TransferTokens.TokenUpdate.Response['data'],
TransferTokens.TokenUpdate.Params & TransferTokens.TokenUpdate.Request['body']
>({
query: ({ id, ...body }) => ({
url: `/admin/transfer/tokens/${id}`,
method: 'PUT',
data: body,
}),
transformResponse: (response: TransferTokens.TokenUpdate.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'TransferToken' as const, id }],
}),
transformResponse: (response: TransferTokens.TokenUpdate.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'TransferToken' as const, id }],
}),
}),
overrideExisting: false,
});
overrideExisting: false,
});
const {
useGetTransferTokensQuery,
@ -68,6 +79,7 @@ const {
useCreateTransferTokenMutation,
useDeleteTransferTokenMutation,
useUpdateTransferTokenMutation,
useRegenerateTokenMutation,
} = transferTokenService;
export {
@ -76,4 +88,5 @@ export {
useCreateTransferTokenMutation,
useDeleteTransferTokenMutation,
useUpdateTransferTokenMutation,
useRegenerateTokenMutation,
};

View File

@ -6,190 +6,194 @@ import { adminApi } from './api';
import type { Data } from '@strapi/types';
const usersService = adminApi.injectEndpoints({
endpoints: (builder) => ({
/**
* users
*/
createUser: builder.mutation<Users.Create.Response['data'], Users.Create.Request['body']>({
query: (body) => ({
url: '/admin/users',
method: 'POST',
data: body,
const usersService = adminApi
.enhanceEndpoints({
addTagTypes: ['LicenseLimits', 'User', 'Role', 'RolePermissions'],
})
.injectEndpoints({
endpoints: (builder) => ({
/**
* users
*/
createUser: builder.mutation<Users.Create.Response['data'], Users.Create.Request['body']>({
query: (body) => ({
url: '/admin/users',
method: 'POST',
data: body,
}),
transformResponse: (response: Users.Create.Response) => response.data,
invalidatesTags: ['LicenseLimits', { type: 'User', id: 'LIST' }],
}),
transformResponse: (response: Users.Create.Response) => response.data,
invalidatesTags: ['LicenseLimits', { type: 'User', id: 'LIST' }],
}),
updateUser: builder.mutation<
Users.Update.Response['data'],
Omit<Users.Update.Request['body'] & Users.Update.Params, 'blocked'>
>({
query: ({ id, ...body }) => ({
url: `/admin/users/${id}`,
method: 'PUT',
data: body,
updateUser: builder.mutation<
Users.Update.Response['data'],
Omit<Users.Update.Request['body'] & Users.Update.Params, 'blocked'>
>({
query: ({ id, ...body }) => ({
url: `/admin/users/${id}`,
method: 'PUT',
data: body,
}),
invalidatesTags: (_res, _err, { id }) => [
{ type: 'User', id },
{ type: 'User', id: 'LIST' },
],
}),
invalidatesTags: (_res, _err, { id }) => [
{ type: 'User', id },
{ type: 'User', id: 'LIST' },
],
}),
getUsers: builder.query<
{
users: Users.FindAll.Response['data']['results'];
pagination: Users.FindAll.Response['data']['pagination'] | null;
},
GetUsersParams
>({
query: ({ id, ...params } = {}) => ({
url: `/admin/users/${id ?? ''}`,
method: 'GET',
config: {
params,
getUsers: builder.query<
{
users: Users.FindAll.Response['data']['results'];
pagination: Users.FindAll.Response['data']['pagination'] | null;
},
}),
transformResponse: (res: Users.FindAll.Response | Users.FindOne.Response) => {
let users: Users.FindAll.Response['data']['results'] = [];
GetUsersParams
>({
query: ({ id, ...params } = {}) => ({
url: `/admin/users/${id ?? ''}`,
method: 'GET',
config: {
params,
},
}),
transformResponse: (res: Users.FindAll.Response | Users.FindOne.Response) => {
let users: Users.FindAll.Response['data']['results'] = [];
if (res.data) {
if ('results' in res.data) {
if (Array.isArray(res.data.results)) {
users = res.data.results;
if (res.data) {
if ('results' in res.data) {
if (Array.isArray(res.data.results)) {
users = res.data.results;
}
} else {
users = [res.data];
}
} else {
users = [res.data];
}
}
return {
users,
pagination: 'pagination' in res.data ? res.data.pagination : null,
};
},
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'User' as const, id: arg.id }];
} else {
return [
...(res?.users.map(({ id }) => ({ type: 'User' as const, id })) ?? []),
{ type: 'User' as const, id: 'LIST' },
];
}
},
}),
deleteManyUsers: builder.mutation<
Users.DeleteMany.Response['data'],
Users.DeleteMany.Request['body']
>({
query: (body) => ({
url: '/admin/users/batch-delete',
method: 'POST',
data: body,
}),
transformResponse: (res: Users.DeleteMany.Response) => res.data,
invalidatesTags: ['LicenseLimits', { type: 'User', id: 'LIST' }],
}),
/**
* roles
*/
createRole: builder.mutation<Roles.Create.Response['data'], Roles.Create.Request['body']>({
query: (body) => ({
url: '/admin/roles',
method: 'POST',
data: body,
}),
transformResponse: (res: Roles.Create.Response) => res.data,
invalidatesTags: [{ type: 'Role', id: 'LIST' }],
}),
getRoles: builder.query<Roles.FindRoles.Response['data'], GetRolesParams | void>({
query: ({ id, ...params } = {}) => ({
url: `/admin/roles/${id ?? ''}`,
method: 'GET',
config: {
params,
return {
users,
pagination: 'pagination' in res.data ? res.data.pagination : null,
};
},
}),
transformResponse: (res: Roles.FindRole.Response | Roles.FindRoles.Response) => {
let roles: Roles.FindRoles.Response['data'] = [];
if (res.data) {
if (Array.isArray(res.data)) {
roles = res.data;
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'User' as const, id: arg.id }];
} else {
roles = [res.data];
return [
...(res?.users.map(({ id }) => ({ type: 'User' as const, id })) ?? []),
{ type: 'User' as const, id: 'LIST' },
];
}
}
},
}),
deleteManyUsers: builder.mutation<
Users.DeleteMany.Response['data'],
Users.DeleteMany.Request['body']
>({
query: (body) => ({
url: '/admin/users/batch-delete',
method: 'POST',
data: body,
}),
transformResponse: (res: Users.DeleteMany.Response) => res.data,
invalidatesTags: ['LicenseLimits', { type: 'User', id: 'LIST' }],
}),
/**
* roles
*/
createRole: builder.mutation<Roles.Create.Response['data'], Roles.Create.Request['body']>({
query: (body) => ({
url: '/admin/roles',
method: 'POST',
data: body,
}),
transformResponse: (res: Roles.Create.Response) => res.data,
invalidatesTags: [{ type: 'Role', id: 'LIST' }],
}),
getRoles: builder.query<Roles.FindRoles.Response['data'], GetRolesParams | void>({
query: ({ id, ...params } = {}) => ({
url: `/admin/roles/${id ?? ''}`,
method: 'GET',
config: {
params,
},
}),
transformResponse: (res: Roles.FindRole.Response | Roles.FindRoles.Response) => {
let roles: Roles.FindRoles.Response['data'] = [];
return roles;
},
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'Role' as const, id: arg.id }];
} else {
return [
...(res?.map(({ id }) => ({ type: 'Role' as const, id })) ?? []),
{ type: 'Role' as const, id: 'LIST' },
];
}
},
}),
updateRole: builder.mutation<
Roles.Update.Response['data'],
Roles.Update.Request['body'] & Roles.Update.Request['params']
>({
query: ({ id, ...body }) => ({
url: `/admin/roles/${id}`,
method: 'PUT',
data: body,
}),
transformResponse: (res: Roles.Create.Response) => res.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'Role' as const, id }],
}),
getRolePermissions: builder.query<
Roles.GetPermissions.Response['data'],
GetRolePermissionsParams
>({
query: ({ id, ...params }) => ({
url: `/admin/roles/${id}/permissions`,
method: 'GET',
config: {
params,
if (res.data) {
if (Array.isArray(res.data)) {
roles = res.data;
} else {
roles = [res.data];
}
}
return roles;
},
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'Role' as const, id: arg.id }];
} else {
return [
...(res?.map(({ id }) => ({ type: 'Role' as const, id })) ?? []),
{ type: 'Role' as const, id: 'LIST' },
];
}
},
}),
transformResponse: (res: Roles.GetPermissions.Response) => res.data,
providesTags: (_res, _err, { id }) => [{ type: 'RolePermissions' as const, id }],
}),
updateRolePermissions: builder.mutation<
Roles.UpdatePermissions.Response['data'],
Roles.UpdatePermissions.Request['body'] & Roles.UpdatePermissions.Request['params']
>({
query: ({ id, ...body }) => ({
url: `/admin/roles/${id}/permissions`,
method: 'PUT',
data: body,
updateRole: builder.mutation<
Roles.Update.Response['data'],
Roles.Update.Request['body'] & Roles.Update.Request['params']
>({
query: ({ id, ...body }) => ({
url: `/admin/roles/${id}`,
method: 'PUT',
data: body,
}),
transformResponse: (res: Roles.Create.Response) => res.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'Role' as const, id }],
}),
transformResponse: (res: Roles.UpdatePermissions.Response) => res.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'RolePermissions' as const, id }],
}),
/**
* Permissions
*/
getRolePermissionLayout: builder.query<
Permissions.GetAll.Response['data'],
Permissions.GetAll.Request['params']
>({
query: (params) => ({
url: '/admin/permissions',
method: 'GET',
config: {
params,
},
getRolePermissions: builder.query<
Roles.GetPermissions.Response['data'],
GetRolePermissionsParams
>({
query: ({ id, ...params }) => ({
url: `/admin/roles/${id}/permissions`,
method: 'GET',
config: {
params,
},
}),
transformResponse: (res: Roles.GetPermissions.Response) => res.data,
providesTags: (_res, _err, { id }) => [{ type: 'RolePermissions' as const, id }],
}),
updateRolePermissions: builder.mutation<
Roles.UpdatePermissions.Response['data'],
Roles.UpdatePermissions.Request['body'] & Roles.UpdatePermissions.Request['params']
>({
query: ({ id, ...body }) => ({
url: `/admin/roles/${id}/permissions`,
method: 'PUT',
data: body,
}),
transformResponse: (res: Roles.UpdatePermissions.Response) => res.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'RolePermissions' as const, id }],
}),
/**
* Permissions
*/
getRolePermissionLayout: builder.query<
Permissions.GetAll.Response['data'],
Permissions.GetAll.Request['params']
>({
query: (params) => ({
url: '/admin/permissions',
method: 'GET',
config: {
params,
},
}),
transformResponse: (res: Permissions.GetAll.Response) => res.data,
}),
transformResponse: (res: Permissions.GetAll.Response) => res.data,
}),
}),
overrideExisting: false,
});
overrideExisting: false,
});
type GetUsersParams =
| Users.FindOne.Params

View File

@ -2,85 +2,89 @@ import * as Webhooks from '../../../shared/contracts/webhooks';
import { adminApi } from './api';
const webhooksSerivce = adminApi.injectEndpoints({
endpoints: (builder) => ({
getWebhooks: builder.query<
Webhooks.GetWebhooks.Response['data'],
Webhooks.GetWebhook.Params | void
>({
query: (args) => ({
url: `/admin/webhooks/${args?.id ?? ''}`,
method: 'GET',
const webhooksSerivce = adminApi
.enhanceEndpoints({
addTagTypes: ['Webhook'],
})
.injectEndpoints({
endpoints: (builder) => ({
getWebhooks: builder.query<
Webhooks.GetWebhooks.Response['data'],
Webhooks.GetWebhook.Params | void
>({
query: (args) => ({
url: `/admin/webhooks/${args?.id ?? ''}`,
method: 'GET',
}),
transformResponse: (
response: Webhooks.GetWebhooks.Response | Webhooks.GetWebhook.Response
) => {
if (Array.isArray(response.data)) {
return response.data;
} else {
return [response.data];
}
},
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'Webhook' as const, id: arg.id }];
} else {
return [
...(res?.map(({ id }) => ({ type: 'Webhook' as const, id })) ?? []),
{ type: 'Webhook' as const, id: 'LIST' },
];
}
},
}),
transformResponse: (
response: Webhooks.GetWebhooks.Response | Webhooks.GetWebhook.Response
) => {
if (Array.isArray(response.data)) {
return response.data;
} else {
return [response.data];
}
},
providesTags: (res, _err, arg) => {
if (typeof arg === 'object' && 'id' in arg) {
return [{ type: 'Webhook' as const, id: arg.id }];
} else {
return [
...(res?.map(({ id }) => ({ type: 'Webhook' as const, id })) ?? []),
{ type: 'Webhook' as const, id: 'LIST' },
];
}
},
}),
createWebhook: builder.mutation<
Webhooks.CreateWebhook.Response['data'],
Omit<Webhooks.CreateWebhook.Request['body'], 'id' | 'isEnabled'>
>({
query: (body) => ({
url: `/admin/webhooks`,
method: 'POST',
data: body,
createWebhook: builder.mutation<
Webhooks.CreateWebhook.Response['data'],
Omit<Webhooks.CreateWebhook.Request['body'], 'id' | 'isEnabled'>
>({
query: (body) => ({
url: `/admin/webhooks`,
method: 'POST',
data: body,
}),
transformResponse: (response: Webhooks.CreateWebhook.Response) => response.data,
invalidatesTags: [{ type: 'Webhook', id: 'LIST' }],
}),
transformResponse: (response: Webhooks.CreateWebhook.Response) => response.data,
invalidatesTags: [{ type: 'Webhook', id: 'LIST' }],
}),
updateWebhook: builder.mutation<
Webhooks.UpdateWebhook.Response['data'],
Webhooks.UpdateWebhook.Request['body'] & Webhooks.UpdateWebhook.Params
>({
query: ({ id, ...body }) => ({
url: `/admin/webhooks/${id}`,
method: 'PUT',
data: body,
updateWebhook: builder.mutation<
Webhooks.UpdateWebhook.Response['data'],
Webhooks.UpdateWebhook.Request['body'] & Webhooks.UpdateWebhook.Params
>({
query: ({ id, ...body }) => ({
url: `/admin/webhooks/${id}`,
method: 'PUT',
data: body,
}),
transformResponse: (response: Webhooks.UpdateWebhook.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'Webhook', id }],
}),
transformResponse: (response: Webhooks.UpdateWebhook.Response) => response.data,
invalidatesTags: (_res, _err, { id }) => [{ type: 'Webhook', id }],
}),
triggerWebhook: builder.mutation<
Webhooks.TriggerWebhook.Response['data'],
Webhooks.TriggerWebhook.Params['id']
>({
query: (webhookId) => ({
url: `/admin/webhooks/${webhookId}/trigger`,
method: 'POST',
triggerWebhook: builder.mutation<
Webhooks.TriggerWebhook.Response['data'],
Webhooks.TriggerWebhook.Params['id']
>({
query: (webhookId) => ({
url: `/admin/webhooks/${webhookId}/trigger`,
method: 'POST',
}),
transformResponse: (response: Webhooks.TriggerWebhook.Response) => response.data,
}),
transformResponse: (response: Webhooks.TriggerWebhook.Response) => response.data,
}),
deleteManyWebhooks: builder.mutation<
Webhooks.DeleteWebhooks.Response['data'],
Webhooks.DeleteWebhooks.Request['body']
>({
query: (body) => ({
url: `/admin/webhooks/batch-delete`,
method: 'POST',
data: body,
deleteManyWebhooks: builder.mutation<
Webhooks.DeleteWebhooks.Response['data'],
Webhooks.DeleteWebhooks.Request['body']
>({
query: (body) => ({
url: `/admin/webhooks/batch-delete`,
method: 'POST',
data: body,
}),
transformResponse: (response: Webhooks.DeleteWebhooks.Response) => response.data,
invalidatesTags: (_res, _err, { ids }) => ids.map((id) => ({ type: 'Webhook', id })),
}),
transformResponse: (response: Webhooks.DeleteWebhooks.Response) => response.data,
invalidatesTags: (_res, _err, { ids }) => ids.map((id) => ({ type: 'Webhook', id })),
}),
}),
overrideExisting: false,
});
overrideExisting: false,
});
const {
useGetWebhooksQuery,

View File

@ -1,30 +1,25 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import {
getFetchClient,
isFetchError,
type FetchOptions,
type Method,
} from '../utils/getFetchClient';
import { getFetchClient, isFetchError, type FetchOptions } from '../utils/getFetchClient';
import type { ApiError } from '../hooks/useAPIErrorHandler';
export interface QueryArguments {
interface QueryArguments {
url: string;
method?: Method;
method?: 'GET' | 'POST' | 'DELETE' | 'PUT';
data?: unknown;
config?: FetchOptions;
}
export interface UnknownApiError {
interface UnknownApiError {
name: 'UnknownError';
message: string;
details?: unknown;
status?: number;
}
export type BaseQueryError = ApiError | UnknownApiError;
type BaseQueryError = ApiError | UnknownApiError;
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
@ -114,3 +109,4 @@ const isBaseQueryError = (error: BaseQueryError | SerializedError): error is Bas
};
export { fetchBaseQuery, isBaseQueryError };
export type { BaseQueryError, UnknownApiError, QueryArguments };

View File

@ -8,35 +8,30 @@ const STORAGE_KEYS = {
USER: 'userInfo',
};
type FetchParams = Parameters<typeof fetch>;
type FetchURL = FetchParams[0];
export type Method = 'GET' | 'POST' | 'DELETE' | 'PUT';
export type FetchResponse<TData = any> = {
type FetchResponse<TData = any> = {
data: TData;
status?: number;
};
export type FetchOptions = {
type FetchOptions = {
params?: any;
signal?: AbortSignal;
headers?: Record<string, string>;
validateStatus?: ((status: number) => boolean) | null;
};
export type FetchConfig = {
type FetchConfig = {
signal?: AbortSignal;
};
type ErrorResponse = {
interface ErrorResponse {
data: {
data?: any;
error: ApiError & { status?: number };
};
};
}
export class FetchError extends Error {
class FetchError extends Error {
public name: string;
public message: string;
public response?: ErrorResponse;
@ -58,7 +53,7 @@ export class FetchError extends Error {
}
}
export const isFetchError = (error: unknown): error is FetchError => {
const isFetchError = (error: unknown): error is FetchError => {
return error instanceof FetchError;
};
@ -161,7 +156,7 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
return url;
};
const addBaseUrl = (url: FetchURL) => {
const addBaseUrl = (url: Parameters<typeof fetch>[0]) => {
return `${backendURL}${url}`;
};
@ -190,6 +185,7 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
method: 'GET',
headers,
});
return responseInterceptor<TData>(response, options?.validateStatus);
},
post: async <TData, TSend = any>(
@ -268,4 +264,5 @@ const getFetchClient = (defaultOptions: FetchConfig = {}): FetchClient => {
return fetchClient;
};
export { getFetchClient };
export { getFetchClient, isFetchError, FetchError };
export type { FetchOptions, FetchResponse, FetchConfig, FetchClient, ErrorResponse };

View File

@ -89,10 +89,10 @@ const Providers = ({ children, initialEntries, storeConfig, permissions = [] }:
},
});
const store = configureStore(
// @ts-expect-error we've not filled up the entire initial state.
storeConfig ?? defaultTestStoreConfig
);
const store = configureStore({
...defaultTestStoreConfig,
...storeConfig,
});
const allPermissions =
typeof permissions === 'function'

View File

@ -12,12 +12,9 @@ export default {
const cm = new ContentManagerPlugin();
app.addReducers({
[contentManagerApi.reducerPath]: contentManagerApi.reducer,
[PLUGIN_ID]: reducer,
});
app.addMiddlewares([() => contentManagerApi.middleware]);
app.addMenuLink({
to: PLUGIN_ID,
icon: Feather,

View File

@ -1,11 +1,7 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { adminApi } from '@strapi/admin/strapi-admin';
import { fetchBaseQuery, type UnknownApiError } from '../utils/api';
const contentManagerApi = createApi({
reducerPath: 'contentManagerApi',
baseQuery: fetchBaseQuery(),
tagTypes: [
const contentManagerApi = adminApi.enhanceEndpoints({
addTagTypes: [
'ComponentConfiguration',
'ContentTypesConfiguration',
'ContentTypeSettings',
@ -14,7 +10,6 @@ const contentManagerApi = createApi({
'HistoryVersion',
'Relations',
],
endpoints: () => ({}),
});
export { contentManagerApi, type UnknownApiError };
export { contentManagerApi };

View File

@ -1,11 +1,5 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import {
getFetchClient,
ApiError,
isFetchError,
type FetchOptions,
} from '@strapi/admin/strapi-admin';
import { ApiError, type UnknownApiError } from '@strapi/admin/strapi-admin';
interface Query {
plugins?: Record<string, unknown>;
@ -47,117 +41,11 @@ const buildValidParams = <TQuery extends Query>(query: TQuery): TransformedQuery
return validQueryParams;
};
export interface QueryArguments {
url: string;
method?: string;
data?: unknown;
config?: FetchOptions;
}
export interface UnknownApiError {
name: 'UnknownError';
message: string;
details?: unknown;
status?: number;
}
export type BaseQueryError = ApiError | UnknownApiError;
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
const { get, post, del, put } = getFetchClient();
if (typeof query === 'string') {
const result = await get(query, {
signal,
});
return { data: result.data };
} else {
const { url, method = 'GET', data, config } = query;
if (method === 'POST') {
const result = await post(url, data, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'DELETE') {
const result = await del(url, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'PUT') {
const result = await put(url, data, {
...config,
signal,
});
return { data: result.data };
}
/**
* Default is GET.
*/
const result = await get(url, {
...config,
signal,
});
return { data: result.data };
}
} catch (err) {
/**
* Handle error of type FetchError
*
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
'error' in err.response?.data
) {
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error as any };
} else {
return {
data: undefined,
error: {
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response,
status: err.status,
} as UnknownApiError,
};
}
}
const error = err as Error;
return {
data: undefined,
error: {
name: error.name,
message: error.message,
stack: error.stack,
} satisfies SerializedError,
};
}
};
type BaseQueryError = ApiError | UnknownApiError;
const isBaseQueryError = (error: BaseQueryError | SerializedError): error is BaseQueryError => {
return error.name !== undefined;
};
export { fetchBaseQuery, isBaseQueryError, buildValidParams };
export { isBaseQueryError, buildValidParams };
export type { BaseQueryError, UnknownApiError };

View File

@ -15,18 +15,15 @@ import {
} from '@strapi/admin/strapi-admin/test';
import { reducer } from '../src/modules/reducers';
import { contentManagerApi } from '../src/services/api';
const storeConfig: ConfigureStoreOptions = {
preloadedState: defaultTestStoreConfig.preloadedState,
reducer: {
...defaultTestStoreConfig.reducer,
[contentManagerApi.reducerPath]: contentManagerApi.reducer,
'content-manager': reducer,
},
middleware: (getDefaultMiddleware) => [
...defaultTestStoreConfig.middleware(getDefaultMiddleware),
contentManagerApi.middleware,
],
};

View File

@ -5,7 +5,6 @@ import { ReleaseAction } from './components/ReleaseAction';
// import { addColumnToTableHook } from './components/ReleaseListCell';
import { PERMISSIONS } from './constants';
import { pluginId } from './pluginId';
import { releaseApi } from './services/release';
import { prefixPluginTranslations } from './utils/prefixPluginTranslations';
import type { StrapiApp } from '@strapi/admin/strapi-admin';
@ -35,17 +34,6 @@ const admin: Plugin.Config.AdminInput = {
position: 2,
});
/**
* For some reason every middleware you pass has to a function
* that returns the actual middleware. It's annoying but no one knows why....
*/
// @ts-expect-error this API needs to be typed better.
app.addMiddlewares([() => releaseApi.middleware]);
app.addReducers({
[releaseApi.reducerPath]: releaseApi.reducer,
});
// Insert the Releases container in the 'right-links' zone of the Content Manager's edit view
app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
name: `${pluginId}-link`,

View File

@ -55,6 +55,7 @@ import {
releaseApi,
} from '../services/release';
import { useTypedDispatch } from '../store/hooks';
import { isBaseQueryError } from '../utils/api';
import { getTimezoneOffset } from '../utils/time';
import { getBadgeProps } from './ReleasesPage';
@ -216,7 +217,6 @@ const ReleaseDetailsLayout = ({
const {
data,
isLoading: isLoadingDetails,
isError,
error,
} = useGetReleaseQuery(
{ id: releaseId! },
@ -301,13 +301,14 @@ const ReleaseDetailsLayout = ({
return <Page.Loading />;
}
if (isError || !release) {
if ((isBaseQueryError(error) && 'code' in error) || !release) {
return (
<Navigate
to=".."
state={{
errors: [
{
// @ts-expect-error TODO: fix this weird error flow
code: error?.code,
},
],
@ -516,7 +517,6 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
const {
data: releaseData,
isLoading: isReleaseLoading,
isError: isReleaseError,
error: releaseError,
} = useGetReleaseQuery({ id: releaseId });
const {
@ -598,14 +598,14 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
const contentTypes = releaseMeta?.contentTypes || {};
const components = releaseMeta?.components || {};
if (isReleaseError || !release) {
if (isBaseQueryError(releaseError) || !release) {
const errorsArray = [];
if (releaseError) {
if (releaseError && 'code' in releaseError) {
errorsArray.push({
code: releaseError.code,
});
}
if (releaseActionsError) {
if (releaseActionsError && 'code' in releaseActionsError) {
errorsArray.push({
code: releaseActionsError.code,
});

View File

@ -1,63 +0,0 @@
import { getFetchClient, type FetchOptions, type FetchError } from '@strapi/admin/strapi-admin';
export interface QueryArguments<TSend> {
url: string;
method: 'PUT' | 'GET' | 'POST' | 'DELETE';
data?: TSend;
config?: FetchOptions;
}
const fetchBaseQuery = async <TData = unknown, TSend = unknown>({
url,
method,
data,
config,
}: QueryArguments<TSend>) => {
try {
const { get, post, del, put } = getFetchClient();
if (method === 'POST') {
const result = await post<TData, TSend>(url, data, config);
return { data: result.data };
}
if (method === 'DELETE') {
const result = await del<TData>(url, config);
return { data: result.data };
}
if (method === 'PUT') {
const result = await put<TData>(url, data, config);
return { data: result.data };
}
/**
* Default is GET.
*/
const result = await get<TData>(url, config);
return { data: result.data };
} catch (error) {
const err = error as FetchError;
/**
* Handle error of type FetchError
*
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
return {
error: {
status: err.status,
code: err.code,
response: {
data: err.response?.data,
},
},
};
}
};
export { fetchBaseQuery };

View File

@ -1,13 +1,10 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { adminApi } from '@strapi/admin/strapi-admin';
import {
CreateReleaseAction,
CreateManyReleaseActions,
DeleteReleaseAction,
} from '../../../shared/contracts/release-actions';
import { pluginId } from '../pluginId';
import { fetchBaseQuery } from './baseQuery';
import type {
GetReleaseActions,
@ -48,257 +45,262 @@ type GetReleasesTabResponse = GetReleases.Response & {
};
};
const releaseApi = createApi({
reducerPath: pluginId,
baseQuery: fetchBaseQuery,
tagTypes: ['Release', 'ReleaseAction', 'EntriesInRelease'],
endpoints: (build) => {
return {
getReleasesForEntry: build.query<
GetContentTypeEntryReleases.Response,
Partial<GetContentTypeEntryReleases.Request['query']>
>({
query(params) {
return {
url: '/content-releases',
method: 'GET',
config: {
params,
},
};
},
providesTags: (result) =>
result
? [
...result.data.map(({ id }) => ({ type: 'Release' as const, id })),
{ type: 'Release', id: 'LIST' },
]
: [],
}),
getReleases: build.query<GetReleasesTabResponse, GetReleasesQueryParams | void>({
query(
{ page, pageSize, filters } = {
page: 1,
pageSize: 16,
filters: {
releasedAt: {
$notNull: false,
const releaseApi = adminApi
.enhanceEndpoints({
addTagTypes: ['Release', 'ReleaseAction', 'EntriesInRelease'],
})
.injectEndpoints({
endpoints: (build) => {
return {
getReleasesForEntry: build.query<
GetContentTypeEntryReleases.Response,
Partial<GetContentTypeEntryReleases.Request['query']>
>({
query(params) {
return {
url: '/content-releases',
method: 'GET',
config: {
params,
},
},
}
) {
return {
url: '/content-releases',
method: 'GET',
config: {
params: {
page: page || 1,
pageSize: pageSize || 16,
filters: filters || {
releasedAt: {
$notNull: false,
};
},
providesTags: (result) =>
result
? [
...result.data.map(({ id }) => ({ type: 'Release' as const, id })),
{ type: 'Release', id: 'LIST' },
]
: [],
}),
getReleases: build.query<GetReleasesTabResponse, GetReleasesQueryParams | void>({
query(
{ page, pageSize, filters } = {
page: 1,
pageSize: 16,
filters: {
releasedAt: {
$notNull: false,
},
},
}
) {
return {
url: '/content-releases',
method: 'GET',
config: {
params: {
page: page || 1,
pageSize: pageSize || 16,
filters: filters || {
releasedAt: {
$notNull: false,
},
},
},
},
},
};
},
transformResponse(response: GetReleasesTabResponse, meta, arg) {
const releasedAtValue = arg?.filters?.releasedAt?.$notNull;
const isActiveDoneTab = releasedAtValue === 'true';
const newResponse: GetReleasesTabResponse = {
...response,
meta: {
...response.meta,
activeTab: isActiveDoneTab ? 'done' : 'pending',
},
};
};
},
transformResponse(response: GetReleasesTabResponse, meta, arg) {
const releasedAtValue = arg?.filters?.releasedAt?.$notNull;
const isActiveDoneTab = releasedAtValue === 'true';
const newResponse: GetReleasesTabResponse = {
...response,
meta: {
...response.meta,
activeTab: isActiveDoneTab ? 'done' : 'pending',
},
};
return newResponse;
},
providesTags: (result) =>
result
? [
...result.data.map(({ id }) => ({ type: 'Release' as const, id })),
{ type: 'Release', id: 'LIST' },
]
: [{ type: 'Release', id: 'LIST' }],
}),
getRelease: build.query<GetRelease.Response, GetRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}`,
method: 'GET',
};
},
providesTags: (result, error, arg) => [{ type: 'Release' as const, id: arg.id }],
}),
getReleaseActions: build.query<
GetReleaseActions.Response,
GetReleaseActions.Request['params'] & GetReleaseActions.Request['query']
>({
query({ releaseId, ...params }) {
return {
url: `/content-releases/${releaseId}/actions`,
method: 'GET',
config: {
params,
},
};
},
providesTags: [{ type: 'ReleaseAction', id: 'LIST' }],
}),
createRelease: build.mutation<CreateRelease.Response, CreateRelease.Request['body']>({
query(data) {
return {
url: '/content-releases',
method: 'POST',
data,
};
},
invalidatesTags: [{ type: 'Release', id: 'LIST' }],
}),
updateRelease: build.mutation<
void,
UpdateRelease.Request['params'] & UpdateRelease.Request['body']
>({
query({ id, ...data }) {
return {
url: `/content-releases/${id}`,
method: 'PUT',
data,
};
},
invalidatesTags: (result, error, arg) => [{ type: 'Release', id: arg.id }],
}),
createReleaseAction: build.mutation<
CreateReleaseAction.Response,
CreateReleaseAction.Request
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions`,
method: 'POST',
data: body,
};
},
invalidatesTags: [
{ type: 'Release', id: 'LIST' },
{ type: 'ReleaseAction', id: 'LIST' },
],
}),
createManyReleaseActions: build.mutation<
CreateManyReleaseActions.Response,
CreateManyReleaseActions.Request
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions/bulk`,
method: 'POST',
data: body,
};
},
invalidatesTags: [
{ type: 'Release', id: 'LIST' },
{ type: 'ReleaseAction', id: 'LIST' },
{ type: 'EntriesInRelease' },
],
}),
updateReleaseAction: build.mutation<
UpdateReleaseAction.Response,
UpdateReleaseAction.Request & { query: GetReleaseActions.Request['query'] } & {
actionPath: [string, number];
}
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions/${params.actionId}`,
method: 'PUT',
data: body,
};
},
invalidatesTags: () => [{ type: 'ReleaseAction', id: 'LIST' }],
async onQueryStarted({ body, params, query, actionPath }, { dispatch, queryFulfilled }) {
// We need to mimic the same params received by the getReleaseActions query
const paramsWithoutActionId = {
releaseId: params.releaseId,
...query,
};
const patchResult = dispatch(
releaseApi.util.updateQueryData('getReleaseActions', paramsWithoutActionId, (draft) => {
const [key, index] = actionPath;
const action = draft.data[key][index];
if (action) {
action.type = body.type;
}
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
return newResponse;
},
providesTags: (result) =>
result
? [
...result.data.map(({ id }) => ({ type: 'Release' as const, id })),
{ type: 'Release', id: 'LIST' },
]
: [{ type: 'Release', id: 'LIST' }],
}),
getRelease: build.query<GetRelease.Response, GetRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}`,
method: 'GET',
};
},
providesTags: (result, error, arg) => [{ type: 'Release' as const, id: arg.id }],
}),
getReleaseActions: build.query<
GetReleaseActions.Response,
GetReleaseActions.Request['params'] & GetReleaseActions.Request['query']
>({
query({ releaseId, ...params }) {
return {
url: `/content-releases/${releaseId}/actions`,
method: 'GET',
config: {
params,
},
};
},
providesTags: [{ type: 'ReleaseAction', id: 'LIST' }],
}),
createRelease: build.mutation<CreateRelease.Response, CreateRelease.Request['body']>({
query(data) {
return {
url: '/content-releases',
method: 'POST',
data,
};
},
invalidatesTags: [{ type: 'Release', id: 'LIST' }],
}),
updateRelease: build.mutation<
void,
UpdateRelease.Request['params'] & UpdateRelease.Request['body']
>({
query({ id, ...data }) {
return {
url: `/content-releases/${id}`,
method: 'PUT',
data,
};
},
invalidatesTags: (result, error, arg) => [{ type: 'Release', id: arg.id }],
}),
createReleaseAction: build.mutation<
CreateReleaseAction.Response,
CreateReleaseAction.Request
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions`,
method: 'POST',
data: body,
};
},
invalidatesTags: [
{ type: 'Release', id: 'LIST' },
{ type: 'ReleaseAction', id: 'LIST' },
],
}),
createManyReleaseActions: build.mutation<
CreateManyReleaseActions.Response,
CreateManyReleaseActions.Request
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions/bulk`,
method: 'POST',
data: body,
};
},
invalidatesTags: [
{ type: 'Release', id: 'LIST' },
{ type: 'ReleaseAction', id: 'LIST' },
{ type: 'EntriesInRelease' },
],
}),
updateReleaseAction: build.mutation<
UpdateReleaseAction.Response,
UpdateReleaseAction.Request & { query: GetReleaseActions.Request['query'] } & {
actionPath: [string, number];
}
},
}),
deleteReleaseAction: build.mutation<
DeleteReleaseAction.Response,
DeleteReleaseAction.Request
>({
query({ params }) {
return {
url: `/content-releases/${params.releaseId}/actions/${params.actionId}`,
method: 'DELETE',
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'Release', id: 'LIST' },
{ type: 'Release', id: arg.params.releaseId },
{ type: 'ReleaseAction', id: 'LIST' },
{ type: 'EntriesInRelease' },
],
}),
publishRelease: build.mutation<PublishRelease.Response, PublishRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}/publish`,
method: 'POST',
};
},
invalidatesTags: (result, error, arg) => [{ type: 'Release', id: arg.id }],
}),
deleteRelease: build.mutation<DeleteRelease.Response, DeleteRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}`,
method: 'DELETE',
};
},
invalidatesTags: () => [{ type: 'Release', id: 'LIST' }, { type: 'EntriesInRelease' }],
}),
getMappedEntriesInReleases: build.query<
MapEntriesToReleases.Response['data'],
MapEntriesToReleases.Request['query']
>({
query(params) {
return {
url: '/content-releases/mapEntriesToReleases',
method: 'GET',
config: {
params,
},
};
},
transformResponse(response: MapEntriesToReleases.Response) {
return response.data;
},
providesTags: [{ type: 'EntriesInRelease' }],
}),
};
},
});
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions/${params.actionId}`,
method: 'PUT',
data: body,
};
},
invalidatesTags: () => [{ type: 'ReleaseAction', id: 'LIST' }],
async onQueryStarted({ body, params, query, actionPath }, { dispatch, queryFulfilled }) {
// We need to mimic the same params received by the getReleaseActions query
const paramsWithoutActionId = {
releaseId: params.releaseId,
...query,
};
const patchResult = dispatch(
releaseApi.util.updateQueryData(
'getReleaseActions',
paramsWithoutActionId,
(draft) => {
const [key, index] = actionPath;
const action = draft.data[key][index];
if (action) {
action.type = body.type;
}
}
)
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
deleteReleaseAction: build.mutation<
DeleteReleaseAction.Response,
DeleteReleaseAction.Request
>({
query({ params }) {
return {
url: `/content-releases/${params.releaseId}/actions/${params.actionId}`,
method: 'DELETE',
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'Release', id: 'LIST' },
{ type: 'Release', id: arg.params.releaseId },
{ type: 'ReleaseAction', id: 'LIST' },
{ type: 'EntriesInRelease' },
],
}),
publishRelease: build.mutation<PublishRelease.Response, PublishRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}/publish`,
method: 'POST',
};
},
invalidatesTags: (result, error, arg) => [{ type: 'Release', id: arg.id }],
}),
deleteRelease: build.mutation<DeleteRelease.Response, DeleteRelease.Request['params']>({
query({ id }) {
return {
url: `/content-releases/${id}`,
method: 'DELETE',
};
},
invalidatesTags: () => [{ type: 'Release', id: 'LIST' }, { type: 'EntriesInRelease' }],
}),
getMappedEntriesInReleases: build.query<
MapEntriesToReleases.Response['data'],
MapEntriesToReleases.Request['query']
>({
query(params) {
return {
url: '/content-releases/mapEntriesToReleases',
method: 'GET',
config: {
params,
},
};
},
transformResponse(response: MapEntriesToReleases.Response) {
return response.data;
},
providesTags: [{ type: 'EntriesInRelease' }],
}),
};
},
});
const {
useGetReleasesQuery,

View File

@ -0,0 +1,11 @@
import { SerializedError } from '@reduxjs/toolkit';
import { ApiError } from '@strapi/admin/strapi-admin';
type BaseQueryError = ApiError | SerializedError;
const isBaseQueryError = (error?: BaseQueryError): error is BaseQueryError => {
return typeof error !== 'undefined' && error.name !== undefined;
};
export { isBaseQueryError };
export type { BaseQueryError };

View File

@ -1,9 +1,7 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import { ConfigureStoreOptions } from '@reduxjs/toolkit';
import {
defaultTestStoreConfig,
render as renderAdmin,
server,
waitFor,
@ -13,19 +11,6 @@ import {
} from '@strapi/admin/strapi-admin/test';
import { PERMISSIONS } from '../src/constants';
import { releaseApi } from '../src/services/release';
const storeConfig: ConfigureStoreOptions = {
preloadedState: defaultTestStoreConfig.preloadedState,
reducer: {
...defaultTestStoreConfig.reducer,
[releaseApi.reducerPath]: releaseApi.reducer,
},
middleware: (getDefaultMiddleware) => [
...defaultTestStoreConfig.middleware(getDefaultMiddleware),
releaseApi.middleware,
],
};
const render = (
ui: React.ReactElement,
@ -33,7 +18,7 @@ const render = (
): ReturnType<typeof renderAdmin> =>
renderAdmin(ui, {
...options,
providerOptions: { storeConfig, permissions: Object.values(PERMISSIONS).flat() },
providerOptions: { permissions: Object.values(PERMISSIONS).flat() },
});
export { render, waitFor, act, screen, server };

View File

@ -1,6 +1,5 @@
import { PLUGIN_ID, FEATURE_ID } from './constants';
import { Panel } from './routes/content-manager/[model]/[id]/components/Panel';
import { reviewWorkflowsApi } from './services/api';
import { addColumnToTableHook } from './utils/cm-hooks';
import { prefixPluginTranslations } from './utils/translations';
@ -10,13 +9,6 @@ import type { Plugin } from '@strapi/types';
const admin: Plugin.Config.AdminInput = {
register(app: StrapiApp) {
if (window.strapi.features.isEnabled(FEATURE_ID)) {
app.addReducers({
[reviewWorkflowsApi.reducerPath]: reviewWorkflowsApi.reducer,
});
// @ts-expect-error TS doesn't want you to extend the middleware.
app.addMiddlewares([() => reviewWorkflowsApi.middleware]);
app.registerHook('Admin/CM/pages/ListView/inject-column-in-table', addColumnToTableHook);
const contentManagerPluginApis = app.getPlugin('content-manager').apis;

View File

@ -119,10 +119,8 @@ describe('AssigneeSelect', () => {
return res.once(
ctx.status(500),
ctx.json({
data: {
error: {
message: 'Server side error message',
},
error: {
message: 'Server side error message',
},
})
);
@ -136,7 +134,7 @@ describe('AssigneeSelect', () => {
await waitFor(() => expect(queryByText('Loading content...')).not.toBeInTheDocument());
await user.click(getByText('John Doe'));
await findByText('There was an unknown error response from the API');
await findByText('Server side error message');
console.error = origConsoleError;
});

View File

@ -34,7 +34,7 @@ import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
import { Stage as IStage, StagePermission } from '../../../../../shared/contracts/review-workflows';
import { useGetRolesQuery } from '../../../services/admin';
import { useGetAdminRolesQuery } from '../../../services/admin';
import { AVAILABLE_COLORS, getStageColorByHex } from '../../../utils/colors';
import { DRAG_DROP_TYPES } from '../constants';
import { useDragAndDrop } from '../hooks/useDragAndDrop';
@ -517,7 +517,7 @@ const PermissionsField = ({ disabled, name, placeholder, required }: Permissions
const allStages = useForm<WorkflowStage[]>('PermissionsField', (state) => state.values.stages);
const onFormValueChange = useForm('PermissionsField', (state) => state.onChange);
const { data: roles = [], isLoading } = useGetRolesQuery();
const { data: roles = [], isLoading } = useGetAdminRolesQuery();
// Super admins always have permissions to do everything and therefore
// there is no point for this role to show up in the role combobox

View File

@ -8,7 +8,7 @@ type RolesResponse = { data: Roles };
const adminApi = reviewWorkflowsApi.injectEndpoints({
endpoints(builder) {
return {
getRoles: builder.query<Roles, void>({
getAdminRoles: builder.query<Roles, void>({
query: () => ({
url: `/admin/roles`,
method: 'GET',
@ -21,7 +21,7 @@ const adminApi = reviewWorkflowsApi.injectEndpoints({
},
});
const { useGetRolesQuery } = adminApi;
const { useGetAdminRolesQuery } = adminApi;
export { useGetRolesQuery };
export { useGetAdminRolesQuery };
export type { SanitizedAdminUser, Roles };

View File

@ -1,12 +1,7 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { adminApi } from '@strapi/admin/strapi-admin';
import { fetchBaseQuery, type UnknownApiError } from '../utils/api';
const reviewWorkflowsApi = createApi({
reducerPath: 'reviewWorkflowsApi',
baseQuery: fetchBaseQuery(),
tagTypes: ['ReviewWorkflow', 'ReviewWorkflowStages'],
endpoints: () => ({}),
const reviewWorkflowsApi = adminApi.enhanceEndpoints({
addTagTypes: ['ReviewWorkflow', 'ReviewWorkflowStages'],
});
export { reviewWorkflowsApi, type UnknownApiError };
export { reviewWorkflowsApi };

View File

@ -87,6 +87,7 @@ const contentManagerApi = reviewWorkflowsApi.injectEndpoints({
},
}),
}),
overrideExisting: true,
});
const {

View File

@ -1,119 +1,8 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchOptions,
} from '@strapi/admin/strapi-admin';
export interface QueryArguments {
url: string;
method?: string;
data?: unknown;
config?: FetchOptions;
}
export interface UnknownApiError {
name: 'UnknownError';
message: string;
details?: unknown;
status?: number;
}
import { type UnknownApiError, type ApiError } from '@strapi/admin/strapi-admin';
export type BaseQueryError = ApiError | UnknownApiError | SerializedError;
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
const { get, post, del, put } = getFetchClient();
if (typeof query === 'string') {
const result = await get(query, { signal });
return { data: result.data };
} else {
const { url, method = 'GET', data, config } = query;
if (method === 'POST') {
const result = await post(url, data, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'DELETE') {
const result = await del(url, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'PUT') {
const result = await put(url, data, {
...config,
signal,
});
return { data: result.data };
}
/**
* Default is GET.
*/
const result = await get(url, {
...config,
signal,
});
return { data: result.data };
}
} catch (err) {
/**
* Handle error of type FetchError
*
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
'error' in err.response?.data
) {
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error };
} else {
return {
data: undefined,
error: {
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response,
status: err.status,
} as UnknownApiError,
};
}
}
const error = err as Error;
return {
data: undefined,
error: {
name: error.name,
message: error.message,
stack: error.stack,
} satisfies SerializedError,
};
}
};
const isBaseQueryError = (error: BaseQueryError): error is ApiError | UnknownApiError => {
return error.name !== undefined;
};
@ -158,4 +47,4 @@ const buildValidParams = <TQuery extends Query>(query: TQuery): TransformedQuery
return validQueryParams;
};
export { fetchBaseQuery, isBaseQueryError, buildValidParams };
export { isBaseQueryError, buildValidParams };

View File

@ -1,45 +1,5 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import { ConfigureStoreOptions } from '@reduxjs/toolkit';
import {
defaultTestStoreConfig,
render as renderAdmin,
server,
waitFor,
act,
screen,
type RenderOptions,
renderHook as renderHookAdmin,
} from '@strapi/admin/strapi-admin/test';
import { reviewWorkflowsApi } from '../src/services/api';
const storeConfig: ConfigureStoreOptions = {
preloadedState: defaultTestStoreConfig.preloadedState,
reducer: {
...defaultTestStoreConfig.reducer,
[reviewWorkflowsApi.reducerPath]: reviewWorkflowsApi.reducer,
},
middleware: (getDefaultMiddleware) => [
...defaultTestStoreConfig.middleware(getDefaultMiddleware),
reviewWorkflowsApi.middleware,
],
};
const render = (
ui: React.ReactElement,
options: RenderOptions = {}
): ReturnType<typeof renderAdmin> =>
renderAdmin(ui, {
...options,
providerOptions: { storeConfig },
});
const renderHook: typeof renderHookAdmin = (hook, options) =>
renderHookAdmin(hook, {
...options,
providerOptions: { storeConfig },
});
import { render, server, waitFor, act, screen, renderHook } from '@strapi/admin/strapi-admin/test';
export { renderHook, render, waitFor, act, screen, server };

View File

@ -2,7 +2,6 @@ import { Information } from '@strapi/icons';
import { PERMISSIONS } from './constants';
import { pluginId } from './pluginId';
import { api } from './services/api';
import { prefixPluginTranslations } from './utils/prefixPluginTranslations';
// eslint-disable-next-line import/no-default-export
@ -23,12 +22,6 @@ export default {
position: 9,
});
app.addMiddlewares([() => api.middleware]);
app.addReducers({
[api.reducerPath]: api.reducer,
});
app.registerPlugin({
id: pluginId,
name: pluginId,

View File

@ -1,31 +1,10 @@
import * as React from 'react';
import {
render,
waitFor,
defaultTestStoreConfig,
type RenderOptions,
} from '@strapi/strapi/admin/test';
import { render, waitFor, type RenderOptions } from '@strapi/strapi/admin/test';
import { api } from '../../services/api';
import { App } from '../App';
const renderApp = (opts?: RenderOptions) =>
render(<App />, {
...(opts ?? {}),
providerOptions: {
...opts?.providerOptions,
storeConfig: {
...defaultTestStoreConfig,
reducer: {
...defaultTestStoreConfig.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
defaultTestStoreConfig.middleware(getDefaultMiddleware).concat(api.middleware),
},
},
});
const renderApp = (opts?: RenderOptions) => render(<App />, opts);
const versions = ['2.0.0', '1.2.0', '1.0.0'];

View File

@ -1,31 +1,12 @@
import * as React from 'react';
import { fireEvent, render, waitFor, defaultTestStoreConfig } from '@strapi/strapi/admin/test';
import { fireEvent, render, waitFor } from '@strapi/strapi/admin/test';
import { rest } from 'msw';
// @ts-expect-error - js file
import { server } from '../../../../tests/server';
import { api } from '../../services/api';
import { SettingsPage } from '../Settings';
const renderSettingsPage = () =>
render(<SettingsPage />, {
providerOptions: {
storeConfig: {
...defaultTestStoreConfig,
reducer: {
...defaultTestStoreConfig.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefaultMiddleware) =>
defaultTestStoreConfig.middleware(getDefaultMiddleware).concat(api.middleware),
},
},
});
describe('SettingsPage', () => {
it('renders the setting page correctly', async () => {
const { getByRole, queryByText, getByText } = renderSettingsPage();
const { getByRole, queryByText, getByText } = render(<SettingsPage />);
await waitFor(() => expect(queryByText('Loading content.')).not.toBeInTheDocument());
@ -50,7 +31,7 @@ describe('SettingsPage', () => {
})
);
const { getByLabelText, queryByText } = renderSettingsPage();
const { getByLabelText, queryByText } = render(<SettingsPage />);
await waitFor(() => expect(queryByText('Loading content.')).not.toBeInTheDocument());
@ -60,7 +41,7 @@ describe('SettingsPage', () => {
});
it('should render the password field when the Restricted Access checkbox is checked', async () => {
const { getByRole, getByLabelText, queryByText } = renderSettingsPage();
const { getByRole, getByLabelText, queryByText } = render(<SettingsPage />);
await waitFor(() => expect(queryByText('Loading content.')).not.toBeInTheDocument());
@ -72,7 +53,7 @@ describe('SettingsPage', () => {
});
it('should allow me to type a password and save that settings change successfully', async () => {
const { getByRole, getByLabelText, queryByText, user, findByText } = renderSettingsPage();
const { getByRole, getByLabelText, queryByText, user, findByText } = render(<SettingsPage />);
await waitFor(() => expect(queryByText('Loading content.')).not.toBeInTheDocument());

View File

@ -1,53 +1,51 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { adminApi } from '@strapi/admin/strapi-admin';
import { DocumentInfos } from '../types';
import { baseQuery } from '../utils/baseQuery';
type SettingsInput = {
restrictedAccess: boolean;
password: string;
};
const api = createApi({
reducerPath: 'plugin::documentation',
baseQuery: baseQuery(),
tagTypes: ['DocumentInfo'],
endpoints: (builder) => {
return {
getInfo: builder.query<DocumentInfos, void>({
query: () => '/documentation/getInfos',
providesTags: ['DocumentInfo'],
}),
deleteVersion: builder.mutation<void, { version: string }>({
query: ({ version }) => ({
url: `/documentation/deleteDoc/${version}`,
method: 'DELETE',
const api = adminApi
.enhanceEndpoints({
addTagTypes: ['DocumentInfo'],
})
.injectEndpoints({
endpoints: (builder) => {
return {
getInfo: builder.query<DocumentInfos, void>({
query: () => '/documentation/getInfos',
providesTags: ['DocumentInfo'],
}),
invalidatesTags: ['DocumentInfo'],
}),
updateSettings: builder.mutation<void, { body: SettingsInput }>({
query: ({ body }) => ({
url: `/documentation/updateSettings`,
method: 'PUT',
data: body,
deleteVersion: builder.mutation<void, { version: string }>({
query: ({ version }) => ({
url: `/documentation/deleteDoc/${version}`,
method: 'DELETE',
}),
invalidatesTags: ['DocumentInfo'],
}),
invalidatesTags: ['DocumentInfo'],
}),
regenerateDoc: builder.mutation<void, { version: string }>({
query: ({ version }) => ({
url: `/documentation/regenerateDoc`,
method: 'POST',
data: { version },
updateSettings: builder.mutation<void, { body: SettingsInput }>({
query: ({ body }) => ({
url: `/documentation/updateSettings`,
method: 'PUT',
data: body,
}),
invalidatesTags: ['DocumentInfo'],
}),
}),
};
},
});
export { api };
regenerateDoc: builder.mutation<void, { version: string }>({
query: ({ version }) => ({
url: `/documentation/regenerateDoc`,
method: 'POST',
data: { version },
}),
}),
};
},
});
export const {
useGetInfoQuery,

View File

@ -1,121 +1,10 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchOptions,
} from '@strapi/strapi/admin';
import { type UnknownApiError, type ApiError } from '@strapi/strapi/admin';
export interface QueryArguments {
url: string;
method?: string;
data?: unknown;
config?: FetchOptions;
}
export interface UnknownApiError {
name: 'UnknownError';
message: string;
details?: unknown;
status?: number;
}
export type BaseQueryError = ApiError | UnknownApiError | SerializedError;
const baseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
const { get, post, del, put } = getFetchClient();
if (typeof query === 'string') {
const result = await get(query, { signal });
return { data: result.data };
} else {
const { url, method = 'GET', data, config } = query;
if (method === 'POST') {
const result = await post(url, data, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'DELETE') {
const result = await del(url, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'PUT') {
const result = await put(url, data, {
...config,
signal,
});
return { data: result.data };
}
/**
* Default is GET.
*/
const result = await get(url, {
...config,
signal,
});
return { data: result.data };
}
} catch (err) {
/**
* Handle error of type FetchError
*
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
'error' in err.response?.data
) {
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error };
} else {
return {
data: undefined,
error: {
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response?.data,
status: err.status,
} as UnknownApiError,
};
}
}
const error = err as Error;
return {
data: undefined,
error: {
name: error.name,
message: error.message,
stack: error.stack,
} satisfies SerializedError,
};
}
};
type BaseQueryError = ApiError | UnknownApiError | SerializedError;
const isBaseQueryError = (error: BaseQueryError): error is ApiError | UnknownApiError => {
return error.name !== undefined;
};
export { baseQuery, isBaseQueryError };
export { isBaseQueryError };

View File

@ -3,5 +3,5 @@
module.exports = {
preset: '../../../jest-preset.front.js',
displayName: 'Documentation plugin',
setupFilesAfterEnv: ['./tests/setup.js'],
setupFilesAfterEnv: ['./tests/setup.ts'],
};

View File

@ -1,7 +1,5 @@
'use strict';
const { setupServer } = require('msw/node');
const { rest } = require('msw');
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('*/getInfos', (req, res, ctx) => {
@ -32,6 +30,4 @@ const handlers = [
const server = setupServer(...handlers);
module.exports = {
server,
};
export { server };

View File

@ -1,6 +1,4 @@
'use strict';
const { server } = require('./server');
import { server } from './server';
beforeAll(() => {
server.listen();

View File

@ -1,12 +1,7 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { adminApi } from '@strapi/admin/strapi-admin';
import { fetchBaseQuery, type UnknownApiError } from '../utils/baseQuery';
const i18nApi = createApi({
reducerPath: 'i18nApi',
baseQuery: fetchBaseQuery(),
tagTypes: ['Locale'],
endpoints: () => ({}),
const i18nApi = adminApi.enhanceEndpoints({
addTagTypes: ['Locale'],
});
export { i18nApi, type UnknownApiError };
export { i18nApi };

View File

@ -3,6 +3,7 @@ import { i18nApi } from './api';
import type { CountManyEntriesDraftRelations } from '../../../shared/contracts/content-manager';
const relationsApi = i18nApi.injectEndpoints({
overrideExisting: true,
endpoints: (builder) => ({
getManyDraftRelationCount: builder.query<
CountManyEntriesDraftRelations.Response['data'],

View File

@ -1,121 +1,10 @@
import { SerializedError } from '@reduxjs/toolkit';
import { BaseQueryFn } from '@reduxjs/toolkit/query';
import {
getFetchClient,
isFetchError,
type ApiError,
type FetchOptions,
} from '@strapi/admin/strapi-admin';
import { type ApiError, type UnknownApiError } from '@strapi/admin/strapi-admin';
export interface QueryArguments {
url: string;
method?: string;
data?: unknown;
config?: FetchOptions;
}
export interface UnknownApiError {
name: 'UnknownError';
message: string;
details?: unknown;
status?: number;
}
export type BaseQueryError = ApiError | UnknownApiError | SerializedError;
const fetchBaseQuery =
(): BaseQueryFn<string | QueryArguments, unknown, BaseQueryError> =>
async (query, { signal }) => {
try {
const { get, post, del, put } = getFetchClient();
if (typeof query === 'string') {
const result = await get(query, { signal });
return { data: result.data };
} else {
const { url, method = 'GET', data, config } = query;
if (method === 'POST') {
const result = await post(url, data, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'DELETE') {
const result = await del(url, {
...config,
signal,
});
return { data: result.data };
}
if (method === 'PUT') {
const result = await put(url, data, {
...config,
signal,
});
return { data: result.data };
}
/**
* Default is GET.
*/
const result = await get(url, {
...config,
signal,
});
return { data: result.data };
}
} catch (err) {
/**
* Handle error of type FetchError
*
* This format mimics what we want from an FetchError which is what the
* rest of the app works with, except this format is "serializable" since
* it goes into the redux store.
*
* NOTE passing the whole response will highlight this "serializability" issue.
*/
if (isFetchError(err)) {
if (
typeof err.response?.data === 'object' &&
err.response?.data !== null &&
'error' in err.response?.data
) {
/**
* This will most likely be ApiError
*/
return { data: undefined, error: err.response?.data.error };
} else {
return {
data: undefined,
error: {
name: 'UnknownError',
message: 'There was an unknown error response from the API',
details: err.response,
status: err.status,
} as UnknownApiError,
};
}
}
const error = err as Error;
return {
data: undefined,
error: {
name: error.name,
message: error.message,
stack: error.stack,
} satisfies SerializedError,
};
}
};
type BaseQueryError = ApiError | UnknownApiError | SerializedError;
const isBaseQueryError = (error: BaseQueryError): error is ApiError | UnknownApiError => {
return error.name !== undefined;
};
export { fetchBaseQuery, isBaseQueryError };
export { isBaseQueryError };

View File

@ -1,11 +1,9 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import { ConfigureStoreOptions } from '@reduxjs/toolkit';
import {
renderHook as renderHookAdmin,
render as renderAdmin,
defaultTestStoreConfig,
waitFor,
act,
screen,
@ -13,35 +11,22 @@ import {
} from '@strapi/admin/strapi-admin/test';
import { PERMISSIONS } from '../src/constants';
import { i18nApi } from '../src/services/api';
import { server } from './server';
const storeConfig: ConfigureStoreOptions = {
preloadedState: defaultTestStoreConfig.preloadedState,
reducer: {
...defaultTestStoreConfig.reducer,
[i18nApi.reducerPath]: i18nApi.reducer,
},
middleware: (getDefaultMiddleware) => [
...defaultTestStoreConfig.middleware(getDefaultMiddleware),
i18nApi.middleware,
],
};
const render = (
ui: React.ReactElement,
options: RenderOptions = {}
): ReturnType<typeof renderAdmin> =>
renderAdmin(ui, {
...options,
providerOptions: { storeConfig, permissions: Object.values(PERMISSIONS).flat() },
providerOptions: { permissions: Object.values(PERMISSIONS).flat() },
});
const renderHook: typeof renderHookAdmin = (hook, options) =>
renderHookAdmin(hook, {
...options,
providerOptions: { storeConfig, permissions: Object.values(PERMISSIONS).flat() },
providerOptions: { permissions: Object.values(PERMISSIONS).flat() },
});
export { render, renderHook, waitFor, server, act, screen };