feat(cm): set up history page (#19309)

* feat(cm): set up history page

* feat: add injected component

* fix: use React.useId

* fix: typo

Co-authored-by: markkaylor <mark.kaylor@strapi.io>

---------

Co-authored-by: markkaylor <mark.kaylor@strapi.io>
This commit is contained in:
Rémi de Juvigny 2024-01-25 10:36:54 +01:00 committed by GitHub
parent df4560117a
commit ea0fc2822a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 243 additions and 12 deletions

View File

@ -31,6 +31,7 @@ import {
} from './components/InjectionZone';
import { Providers } from './components/Providers';
import { HOOKS } from './constants';
import { InjectedLink } from './content-manager/history/components/InjectedLink';
import { routes as cmRoutes } from './content-manager/routes';
import { Components, Component } from './core/apis/Components';
import { CustomFields } from './core/apis/CustomFields';
@ -333,6 +334,13 @@ class StrapiApp {
}
});
// TODO: remove once we can add the link via a document action instead
this.injectContentManagerComponent('editView', 'right-links', {
name: 'history',
Component: InjectedLink,
slug: 'history',
});
if (isFunction(this.customBootstrapConfiguration)) {
this.customBootstrapConfiguration({
addComponents: this.addComponents,

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { LinkButton } from '@strapi/design-system/v2';
import { useQueryParams } from '@strapi/helper-plugin';
import { stringify } from 'qs';
import { NavLink } from 'react-router-dom';
/**
* This is a temporary component to easily access the history page.
* TODO: delete it when the document actions API is ready
*/
const InjectedLink = () => {
const [{ query }] = useQueryParams<{ plugins?: Record<string, unknown> }>();
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
return (
// @ts-expect-error - types are not inferred correctly through the as prop.
<LinkButton as={NavLink} variant="primary" to={`history?${pluginsQueryParams}`}>
History
</LinkButton>
);
};
export { InjectedLink };

View File

@ -0,0 +1,44 @@
import * as React from 'react';
import { ContentLayout, HeaderLayout, Main, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { ArrowLeft } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { NavLink, useNavigate } from 'react-router-dom';
const VersionDetails = () => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const headerId = React.useId();
return (
<Main grow={1} labelledBy={headerId}>
<HeaderLayout
id={headerId}
title="History"
navigationAction={
<Link
startIcon={<ArrowLeft />}
onClick={(e) => {
e.preventDefault();
navigate(-1);
}}
as={NavLink}
// @ts-expect-error - types are not inferred correctly through the as prop.
to=""
>
{formatMessage({
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>
}
/>
<ContentLayout>
<Typography>Content</Typography>
</ContentLayout>
</Main>
);
};
export { VersionDetails };

View File

@ -0,0 +1,20 @@
import * as React from 'react';
import { Box, Typography } from '@strapi/design-system';
const VersionsList = () => {
return (
<Box
width="320px"
minHeight="100vh"
background="neutral0"
borderColor="neutral200"
borderWidth="0 0 0 1px"
borderStyle="solid"
>
<Typography>Sidebar</Typography>
</Box>
);
};
export { VersionsList };

View File

@ -0,0 +1,46 @@
import { Flex } from '@strapi/design-system';
import { LoadingIndicatorPage } from '@strapi/helper-plugin';
import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { useContentTypeLayout } from '../../hooks/useLayouts';
import { VersionDetails } from '../components/VersionDetails';
import { VersionsList } from '../components/VersionsList';
const HistoryPage = () => {
const { formatMessage } = useIntl();
const { slug } = useParams<{
collectionType: string;
singleType: string;
slug: string;
}>();
const { isLoading, layout } = useContentTypeLayout(slug);
if (isLoading) {
return <LoadingIndicatorPage />;
}
return (
<>
<Helmet
title={formatMessage(
{
id: 'content-manager.history.page-title',
defaultMessage: '{contentType} history',
},
{
contentType: layout?.contentType.info.displayName,
}
)}
/>
<Flex direction="row" alignItems="flex-start">
<VersionDetails />
<VersionsList />
</Flex>
</>
);
};
export { HistoryPage };

View File

@ -0,0 +1,41 @@
import { render, screen, waitFor } from '@tests/utils';
import { Route, Routes } from 'react-router-dom';
import { HistoryPage } from '../History';
describe('History page', () => {
it('renders single type correctly', async () => {
render(
<Routes>
<Route path="/content-manager/:singleType/:slug/history" element={<HistoryPage />} />
</Routes>,
{
initialEntries: ['/content-manager/single-types/api::address.address/history'],
}
);
await waitFor(() => {
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
expect(document.title).toBe('Address history');
});
it('renders collection type correctly', async () => {
render(
<Routes>
<Route
path="/content-manager/:collectionType/:slug/:id/history"
element={<HistoryPage />}
/>
</Routes>,
{
initialEntries: ['/content-manager/collection-types/api::address.address/1/history'],
}
);
await waitFor(() => {
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
expect(document.title).toBe('Address history');
});
});

View File

@ -1,9 +1,42 @@
/* eslint-disable check-file/filename-naming-convention */
import { type RouteObject } from 'react-router-dom';
import { useLocation, type RouteObject, matchRoutes } from 'react-router-dom';
/**
* These routes will be merged with the rest of the Content Manager routes
*/
const routes: RouteObject[] = [];
const routes: RouteObject[] = [
{
path: ':collectionType/:slug/:id/history',
lazy: async () => {
const { HistoryPage } = await import('./pages/History');
export { routes };
return {
Component: HistoryPage,
};
},
},
{
path: ':singleType/:slug/history',
lazy: async () => {
const { HistoryPage } = await import('./pages/History');
return {
Component: HistoryPage,
};
},
},
];
/**
* Used to determine if we're on a history route from the admin and the content manager,
* so that we can hide the left menus on all history routes
*/
function useIsHistoryRoute() {
const location = useLocation();
const historyRoutes = routes.map((route) => ({ path: `content-manager/${route.path}` }));
const matches = matchRoutes(historyRoutes, location);
return Boolean(matches);
}
export { routes, useIsHistoryRoute };

View File

@ -14,6 +14,7 @@ import { CardDragPreview } from '../components/DragPreviews/CardDragPreview';
import { ComponentDragPreview } from '../components/DragPreviews/ComponentDragPreview';
import { RelationDragPreview } from '../components/DragPreviews/RelationDragPreview';
import { LeftMenu } from '../components/LeftMenu';
import { useIsHistoryRoute } from '../history/routes';
import { useContentManagerInitData } from '../hooks/useContentManagerInitData';
import { ItemTypes } from '../utils/dragAndDrop';
import { getTranslation } from '../utils/translations';
@ -38,6 +39,9 @@ const App = () => {
const { startSection } = useGuidedTour();
const startSectionRef = React.useRef(startSection);
// Check if we're on a history route to known if we should render the left menu
const isHistoryRoute = useIsHistoryRoute();
React.useEffect(() => {
if (startSectionRef.current) {
startSectionRef.current('contentManager');
@ -100,10 +104,14 @@ const App = () => {
defaultMessage: 'Content Manager',
})}
/>
<Layout sideNav={<LeftMenu />}>
<DragLayer renderItem={renderDraglayerItem} />
{isHistoryRoute ? (
<Outlet />
</Layout>
) : (
<Layout sideNav={<LeftMenu />}>
<DragLayer renderItem={renderDraglayerItem} />
<Outlet />
</Layout>
)}
</>
);
};

View File

@ -1,6 +1,5 @@
/* eslint-disable check-file/filename-naming-convention */
import { UID } from '@strapi/types';
import { Navigate, RouteObject, useLoaderData } from 'react-router-dom';
import { Navigate, type RouteObject, useLoaderData } from 'react-router-dom';
import { routes as historyRoutes } from './history/routes';

View File

@ -22,6 +22,7 @@ import { Onboarding } from '../components/Onboarding';
import { PluginsInitializer } from '../components/PluginsInitializer';
import { PrivateRoute } from '../components/PrivateRoute';
import { RBACProvider } from '../components/RBACProvider';
import { useIsHistoryRoute } from '../content-manager/history/routes';
import { useAuth } from '../features/Auth';
import { useConfiguration } from '../features/Configuration';
import { useMenu } from '../hooks/useMenu';
@ -129,6 +130,9 @@ const AdminLayout = () => {
trackUsage('didAccessAuthenticatedAdministration');
});
// Check if we're on a history route to know if we should render the left menu
const isHistoryRoute = useIsHistoryRoute();
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions
if (isLoadingMenu || isLoadingAppInfo || isLoadingPermissions) {
@ -157,10 +161,12 @@ const AdminLayout = () => {
{formatMessage({ id: 'skipToContent', defaultMessage: 'Skip to content' })}
</SkipToContent>
<Flex alignItems="flex-start">
<LeftMenu
generalSectionLinks={generalSectionLinks}
pluginsSectionLinks={pluginsSectionLinks}
/>
{!isHistoryRoute && (
<LeftMenu
generalSectionLinks={generalSectionLinks}
pluginsSectionLinks={pluginsSectionLinks}
/>
)}
<Box flex={1}>
<Outlet />
<GuidedTourModal />

View File

@ -904,6 +904,7 @@
"content-manager.utils.data-loaded": "The {number, plural, =1 {entry has} other {entries have}} successfully been loaded",
"content-manager.listView.validation.errors.title": "Action required",
"content-manager.listView.validation.errors.message": "Please make sure all fields are valid before publishing (required field, min/max character limit, etc.)",
"content-manager.history.page-title": "{contentType} history",
"dark": "Dark",
"form.button.continue": "Continue",
"form.button.done": "Done",