chore(admin): convert useSettingsMenu to TS (#18616)

* chore(admin): move constants into global constants

* chore(admin): convert useSettingsMenu to TS
This commit is contained in:
Gustav Hansen 2023-11-05 14:45:37 +01:00 committed by GitHub
parent f5b09a8e61
commit 477ea8ee80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 272 additions and 243 deletions

View File

@ -52,7 +52,7 @@ export const App = () => {
defaultValue: ADMIN_PERMISSIONS_CE,
}
);
const routes = useEnterprise<StrapiRoute[] | null, StrapiRoute[], StrapiRoute[]>(
const routes = useEnterprise(
ROUTES_CE,
async () => (await import('../../ee/admin/src/constants')).ROUTES_EE,
{

View File

@ -1,3 +1,5 @@
import { StrapiAppSettingLink } from '@strapi/helper-plugin';
export const ADMIN_PERMISSIONS_CE = {
contentManager: {
main: [],
@ -122,13 +124,22 @@ export const HOOKS = {
export const ACTION_SET_APP_RUNTIME_STATUS = 'StrapiAdmin/APP/SET_APP_RUNTIME_STATUS';
export const ACTION_SET_ADMIN_PERMISSIONS = 'StrapiAdmin/App/SET_ADMIN_PERMISSIONS';
export const SETTINGS_LINKS_CE = () => ({
export interface SettingsMenuLink extends Omit<StrapiAppSettingLink, 'Component' | 'permissions'> {
Component?: never;
lockIcon?: boolean;
}
export type SettingsMenu = {
admin: SettingsMenuLink[];
global: SettingsMenuLink[];
};
export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
global: [
{
intlLabel: { id: 'Settings.application.title', defaultMessage: 'Overview' },
to: '/settings/application-infos',
id: '000-application-infos',
permissions: [],
},
{
intlLabel: { id: 'Settings.webhooks.title', defaultMessage: 'Webhooks' },
@ -182,7 +193,7 @@ export const SETTINGS_LINKS_CE = () => ({
id: 'roles',
},
{
intlLabel: { id: 'global.users' },
intlLabel: { id: 'global.users', defaultMessage: 'Users' },
// Init the search params directly
to: '/settings/users?pageSize=10&page=1&sort=firstname',
id: 'users',

View File

@ -0,0 +1,4 @@
export const useSettingsMenu = jest.fn().mockReturnValue({
isLoading: false,
menu: [],
});

View File

@ -1 +0,0 @@
export { default as useSettingsMenu } from './useSettingsMenu';

View File

@ -1,21 +1,15 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useEnterprise, UseEnterpriseOptions } from '../useEnterprise';
import { useEnterprise } from '../useEnterprise';
const CE_DATA_FIXTURE = ['CE'];
const EE_DATA_FIXTURE = ['EE'];
function setup(
ceData: any,
eeCallback: () => Promise<any>,
options?: UseEnterpriseOptions<any, any, any>
) {
return renderHook(() => useEnterprise(ceData, eeCallback, options));
}
describe('useEnterprise (CE)', () => {
test('Returns CE data', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE);
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE)
);
expect(result.current).toBe(CE_DATA_FIXTURE);
});
@ -31,7 +25,9 @@ describe('useEnterprise (EE)', () => {
});
test('Returns default data on first render and EE data on second', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE);
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE)
);
expect(result.current).toBe(null);
@ -39,11 +35,13 @@ describe('useEnterprise (EE)', () => {
});
test('Combines CE and EE data', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
combine(ceData: string[], eeData: string[]) {
return [...ceData, ...eeData];
},
});
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
combine(ceData, eeData) {
return [...ceData, ...eeData];
},
})
);
expect(result.current).toBe(null);
@ -53,23 +51,29 @@ describe('useEnterprise (EE)', () => {
});
test('Returns EE data without custom combine', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE);
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE)
);
await waitFor(() => expect(result.current).toStrictEqual(EE_DATA_FIXTURE));
});
test('Returns CE data, when enabled is set to false', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
enabled: false,
});
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
enabled: false,
})
);
await waitFor(() => expect(result.current).toStrictEqual(CE_DATA_FIXTURE));
});
test('Returns a custom defaultValue on first render followed by the EE data', async () => {
const { result } = setup(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
defaultValue: false,
});
const { result } = renderHook(() =>
useEnterprise(CE_DATA_FIXTURE, async () => EE_DATA_FIXTURE, {
defaultValue: false,
})
);
expect(result.current).toBe(false);

View File

@ -2,50 +2,49 @@ import * as React from 'react';
import { useCallbackRef } from '@strapi/helper-plugin';
/**
* TODO: this hook needs typing better, it's a bit similar to react-query's useQuery tbf
* We have an async function that returns something, and we can set initialData as well as
* a select function, the return type of the function should infer it all...
*/
function isEnterprise() {
return window.strapi.isEE;
}
export interface UseEnterpriseOptions<
TCEData = unknown,
TEEData = unknown,
TCombinedData = TEEData
> {
defaultValue?: TCEData | TEEData | null;
combine?: (ceData: TCEData, eeData: TEEData) => TCombinedData;
export interface UseEnterpriseOptions<TCEData, TEEData, TDefaultValue, TCombinedValue> {
defaultValue?: TDefaultValue;
combine?: (ceData: TCEData, eeData: TEEData) => TCombinedValue;
enabled?: boolean;
}
export function useEnterprise<TCEData = unknown, TEEData = unknown, TCombinedData = TEEData>(
type UseEnterpriseReturn<TCEData, TEEData, TDefaultValue, TCombinedValue> =
TDefaultValue extends null
? TCEData | TEEData | TCombinedValue | null
: TCEData | TEEData | TCombinedValue | TDefaultValue;
export const useEnterprise = <
TCEData,
TEEData = TCEData,
TCombinedValue = TEEData,
TDefaultValue = TCEData | null
>(
ceData: TCEData,
eeCallback: () => Promise<TEEData>,
{
defaultValue = null,
// @ts-expect-error TODO: fix this type
combine = (ceData, eeData) => eeData,
enabled = true,
}: UseEnterpriseOptions<TCEData, TEEData, TCombinedData> = {}
): null | TCEData | TEEData | TCombinedData {
opts: UseEnterpriseOptions<TCEData, TEEData, TDefaultValue, TCombinedValue> = {}
): UseEnterpriseReturn<TCEData, TEEData, TDefaultValue, TCombinedValue> => {
const { defaultValue = null, combine = (_ceData, eeData) => eeData, enabled = true } = opts;
const eeCallbackRef = useCallbackRef(eeCallback);
const combineCallbackRef = useCallbackRef(combine);
// We have to use a nested object here, because functions (e.g. Components)
// can not be stored as value directly
const [{ data }, setData] = React.useState<{ data: TCEData | TEEData | TCombinedData | null }>({
const [{ data }, setData] = React.useState<{
data: TCEData | TEEData | TDefaultValue | TCombinedValue | null;
}>({
data: isEnterprise() && enabled ? defaultValue : ceData,
});
React.useEffect(() => {
async function importEE() {
const eeData = await eeCallbackRef();
const combinedValue = combineCallbackRef(ceData, eeData);
setData({ data: combineCallbackRef(ceData, eeData) });
setData({ data: combinedValue ? combinedValue : eeData });
}
if (isEnterprise() && enabled) {
@ -53,5 +52,6 @@ export function useEnterprise<TCEData = unknown, TEEData = unknown, TCombinedDat
}
}, [ceData, eeCallbackRef, combineCallbackRef, enabled]);
// @ts-expect-error the hook type assertion works in practice. But seems to have issues here...
return data;
}
};

View File

@ -0,0 +1,181 @@
import * as React from 'react';
import {
hasPermissions,
StrapiAppSetting,
StrapiAppSettingLink,
useRBACProvider,
useStrapiApp,
useAppInfo,
} from '@strapi/helper-plugin';
import sortBy from 'lodash/sortBy';
import { useSelector } from 'react-redux';
import { SETTINGS_LINKS_CE, SettingsMenuLink } from '../constants';
import { selectAdminPermissions } from '../selectors';
import { useEnterprise } from './useEnterprise';
const formatLinks = (menu: SettingsMenuSection[]): SettingsMenuSectionWithDisplayedLinks[] =>
menu.map((menuSection) => {
const formattedLinks = menuSection.links.map((link) => ({
...link,
isDisplayed: false,
}));
return { ...menuSection, links: formattedLinks };
});
interface SettingsMenuLinkWithPermissions extends SettingsMenuLink {
permissions: StrapiAppSettingLink['permissions'];
}
interface SettingsMenuSection extends Omit<StrapiAppSetting, 'links'> {
links: Array<SettingsMenuLinkWithPermissions | StrapiAppSettingLink>;
}
interface SettingsMenuLinkWithPermissionsAndDisplayed extends SettingsMenuLinkWithPermissions {
isDisplayed: boolean;
}
interface StrapiAppSettingLinkWithDisplayed extends StrapiAppSettingLink {
isDisplayed: boolean;
}
interface SettingsMenuSectionWithDisplayedLinks extends Omit<SettingsMenuSection, 'links'> {
links: Array<SettingsMenuLinkWithPermissionsAndDisplayed | StrapiAppSettingLinkWithDisplayed>;
}
type SettingsMenu = SettingsMenuSectionWithDisplayedLinks[];
const useSettingsMenu = (): {
isLoading: boolean;
menu: SettingsMenu;
} => {
const [{ isLoading, menu }, setData] = React.useState<{
isLoading: boolean;
menu: SettingsMenu;
}>({
isLoading: true,
menu: [],
});
const { allPermissions: userPermissions } = useRBACProvider();
const { shouldUpdateStrapi } = useAppInfo();
const { settings } = useStrapiApp();
const permissions = useSelector(selectAdminPermissions);
/**
* memoize the return value of this function to avoid re-computing it on every render
* because it's used in an effect it ends up re-running recursively.
*/
const ceLinks = React.useMemo(() => SETTINGS_LINKS_CE(), []);
const { admin: adminLinks, global: globalLinks } = useEnterprise(
ceLinks,
async () => (await import('../../../ee/admin/src/constants')).SETTINGS_LINKS_EE(),
{
combine(ceLinks, eeLinks) {
return {
admin: [...eeLinks.admin, ...ceLinks.admin],
global: [...ceLinks.global, ...eeLinks.global],
};
},
defaultValue: {
admin: [],
global: [],
},
}
);
const addPermissions = React.useCallback(
(link: SettingsMenuLink) => {
if (!link.id) {
throw new Error('The settings menu item must have an id attribute.');
}
return {
...link,
permissions: permissions.settings?.[link.id]?.main ?? [],
} satisfies SettingsMenuLinkWithPermissions;
},
[permissions.settings]
);
React.useEffect(() => {
const getData = async () => {
interface MenuLinkPermission {
hasPermission: boolean;
sectionIndex: number;
linkIndex: number;
}
const buildMenuPermissions = (sections: SettingsMenuSectionWithDisplayedLinks[]) =>
Promise.all(
sections.reduce<Promise<MenuLinkPermission>[]>((acc, section, sectionIndex) => {
const linksWithPermissions = section.links.map(async (link, linkIndex) => ({
hasPermission: await hasPermissions(userPermissions, link.permissions),
sectionIndex,
linkIndex,
}));
return [...acc, ...linksWithPermissions];
}, [])
);
const menuPermissions = await buildMenuPermissions(sections);
setData((prev) => {
return {
...prev,
isLoading: false,
menu: sections.map((section, sectionIndex) => ({
...section,
links: section.links.map((link, linkIndex) => {
const permission = menuPermissions.find(
(permission) =>
permission.sectionIndex === sectionIndex && permission.linkIndex === linkIndex
);
return {
...link,
isDisplayed: Boolean(permission?.hasPermission),
};
}),
})),
};
});
};
const { global, ...otherSections } = settings;
const sections = formatLinks([
{
...global,
links: sortBy([...global.links, ...globalLinks.map(addPermissions)], (link) => link.id).map(
(link) => ({
...link,
hasNotification: link.id === '000-application-infos' && shouldUpdateStrapi,
})
),
},
{
id: 'permissions',
intlLabel: { id: 'Settings.permissions', defaultMessage: 'Administration Panel' },
links: adminLinks.map(addPermissions),
},
...Object.values(otherSections),
]);
getData();
}, [adminLinks, globalLinks, userPermissions, settings, shouldUpdateStrapi, addPermissions]);
return {
isLoading,
menu: menu.map((menuItem) => ({
...menuItem,
links: menuItem.links.filter((link) => link.isDisplayed),
})),
};
};
export { useSettingsMenu };
export type { SettingsMenu };

View File

@ -1,133 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { hasPermissions, useRBACProvider, useStrapiApp, useAppInfo } from '@strapi/helper-plugin';
import { useSelector } from 'react-redux';
import { SETTINGS_LINKS_CE } from '../../constants';
import { selectAdminPermissions } from '../../selectors';
import { useEnterprise } from '../useEnterprise';
import formatLinks from './utils/formatLinks';
import sortLinks from './utils/sortLinks';
const useSettingsMenu = () => {
const [{ isLoading, menu }, setData] = useState({
isLoading: true,
menu: [],
});
const { allPermissions: userPermissions } = useRBACProvider();
const { shouldUpdateStrapi } = useAppInfo();
const { settings } = useStrapiApp();
const permissions = useSelector(selectAdminPermissions);
/**
* memoize the return value of this function to avoid re-computing it on every render
* because it's used in an effect it ends up re-running recursively.
*/
const ceLinks = useMemo(() => SETTINGS_LINKS_CE(), []);
const { global: globalLinks, admin: adminLinks } = useEnterprise(
ceLinks,
async () => (await import('../../../../ee/admin/src/constants')).SETTINGS_LINKS_EE(),
{
combine(ceLinks, eeLinks) {
return {
admin: [...eeLinks.admin, ...ceLinks.admin],
global: [...ceLinks.global, ...eeLinks.global],
};
},
defaultValue: {
admin: [],
global: [],
},
}
);
const addPermissions = useCallback(
(link) => {
if (!link.id) {
throw new Error('The settings menu item must have an id attribute.');
}
return {
...link,
permissions: permissions.settings?.[link.id]?.main,
};
},
[permissions.settings]
);
useEffect(() => {
const getData = async () => {
const buildMenuPermissions = (sections) =>
Promise.all(
sections.reduce((acc, section, sectionIndex) => {
const buildMenuPermissions = (links) =>
links.map(async (link, linkIndex) => ({
hasPermission: await hasPermissions(userPermissions, link.permissions),
sectionIndex,
linkIndex,
}));
return [...acc, ...buildMenuPermissions(section.links)];
}, [])
);
const menuPermissions = await buildMenuPermissions(sections);
setData((prev) => {
return {
...prev,
isLoading: false,
menu: sections.map((section, sectionIndex) => ({
...section,
links: section.links.map((link, linkIndex) => {
const permission = menuPermissions.find(
(permission) =>
permission.sectionIndex === sectionIndex && permission.linkIndex === linkIndex
);
return {
...link,
isDisplayed: Boolean(permission.hasPermission),
};
}),
})),
};
});
};
const { global, ...otherSections } = settings;
const sections = formatLinks([
{
...settings.global,
links: sortLinks([...settings.global.links, ...globalLinks.map(addPermissions)]).map(
(link) => ({
...link,
hasNotification: link.id === '000-application-infos' && shouldUpdateStrapi,
})
),
},
{
id: 'permissions',
intlLabel: { id: 'Settings.permissions', defaultMessage: 'Administration Panel' },
links: adminLinks.map(addPermissions),
},
...Object.values(otherSections),
]);
getData();
}, [adminLinks, globalLinks, userPermissions, settings, shouldUpdateStrapi, addPermissions]);
const filterMenu = (menuItem) => {
return {
...menuItem,
links: menuItem.links.filter((link) => link.isDisplayed),
};
};
return { isLoading, menu: menu.map(filterMenu) };
};
export default useSettingsMenu;

View File

@ -1,12 +0,0 @@
const formatLinks = (menu) => {
return menu.map((menuSection) => {
const formattedLinks = menuSection.links.map((link) => ({
...link,
isDisplayed: false,
}));
return { ...menuSection, links: formattedLinks };
});
};
export default formatLinks;

View File

@ -1,5 +0,0 @@
import sortBy from 'lodash/sortBy';
const sortLinks = (links) => sortBy(links, (link) => link.id);
export default sortLinks;

View File

@ -1,30 +0,0 @@
import formatLinks from '../formatLinks';
describe('ADMIN | hooks | useSettingsMenu | utils | formatLinks', () => {
it('should add the isDisplayed key to all sections links', () => {
const menu = [
{
links: [{ name: 'link 1' }, { name: 'link 2' }],
},
{
links: [{ name: 'link 3' }, { name: 'link 4' }],
},
];
const expected = [
{
links: [
{ name: 'link 1', isDisplayed: false },
{ name: 'link 2', isDisplayed: false },
],
},
{
links: [
{ name: 'link 3', isDisplayed: false },
{ name: 'link 4', isDisplayed: false },
],
},
];
expect(formatLinks(menu)).toEqual(expected);
});
});

View File

@ -24,7 +24,7 @@ const AuthPage = ({ hasAdmin }: AuthPageProps) => {
LoginCE,
async () => (await import('../../../../ee/admin/src/pages/AuthPage/components/Login')).LoginEE
);
const forms = useEnterprise<FormDictionary, Partial<FormDictionary>, FormDictionary>(
const forms = useEnterprise<FormDictionary, Partial<FormDictionary>>(
FORMS,
async () => (await import('../../../../ee/admin/src/pages/AuthPage/constants')).FORMS,
{

View File

@ -516,8 +516,7 @@ const SOCIAL_LINKS = [
* -----------------------------------------------------------------------------------------------*/
const HomePage = () => {
type CompFn = () => React.JSX.Element;
const Page = useEnterprise<CompFn, CompFn, CompFn>(
const Page = useEnterprise(
HomePageCE,
// eslint-disable-next-line import/no-cycle
async () => (await import('../../../ee/admin/src/pages/HomePage')).HomePageEE

View File

@ -6,8 +6,8 @@ import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';
import { Redirect, Route, Switch, useParams } from 'react-router-dom';
import { useSettingsMenu } from '../../hooks';
import { useEnterprise } from '../../hooks/useEnterprise';
import { useSettingsMenu } from '../../hooks/useSettingsMenu';
import { createRoute } from '../../utils/createRoute';
import SettingsNav from './components/SettingsNav';

View File

@ -10,13 +10,9 @@ import { Route, Router } from 'react-router-dom';
import { SettingsPage } from '..';
import { Theme } from '../../../components/Theme';
import { ThemeToggleProvider } from '../../../components/ThemeToggleProvider';
import { useSettingsMenu } from '../../../hooks';
import { useSettingsMenu } from '../../../hooks/useSettingsMenu';
jest.mock('../../../hooks', () => ({
useSettingsMenu: jest.fn(() => ({ isLoading: false, menu: [] })),
useAppInfo: jest.fn(() => ({ shouldUpdateStrapi: false })),
useThemeToggle: jest.fn(() => ({ currentTheme: 'light', themes: { light: lightTheme } })),
}));
jest.mock('../../../hooks/useSettingsMenu');
jest.mock('react-intl', () => ({
FormattedMessage: ({ id }) => id,

View File

@ -4,6 +4,19 @@ interface PermissionMap {
marketplace: {
main: Permission[];
};
/**
* TODO: remove the use of record to make it "concrete".
*/
settings: Record<
string,
{
main: Permission[];
create: Permission[];
read: Permission[];
update: Permission[];
delete: Permission[];
}
>;
}
export { PermissionMap };

View File

@ -1,5 +1,7 @@
import { AuthResponse } from './pages/AuthResponse';
import type { SettingsMenu } from '../../../admin/src/constants';
export const ADMIN_PERMISSIONS_EE = {
settings: {
auditLogs: {
@ -31,7 +33,7 @@ export const ROUTES_EE = [
// TODO: the constants.js file is imported before the React application is setup and
// therefore `window.strapi` might not exist at import-time. We should probably define
// which constant is available at which stage of the application lifecycle.
export const SETTINGS_LINKS_EE = () => ({
export const SETTINGS_LINKS_EE = (): SettingsMenu => ({
global: [
...(window.strapi.features.isEnabled(window.strapi.features.SSO)
? [