enhancement: add back button fallback support (#21970)

* enhancement: add back button fallback support

* enhancement: add fallback urls

* fix: feedback and fixes
This commit is contained in:
Rémi de Juvigny 2024-11-12 08:48:49 +01:00 committed by GitHub
parent b0db56479d
commit fed213989e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 78 additions and 44 deletions

View File

@ -4,7 +4,7 @@ import { Link, LinkProps } from '@strapi/design-system';
import { ArrowLeft } from '@strapi/icons';
import { produce } from 'immer';
import { useIntl } from 'react-intl';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { NavLink, type To, useLocation, useNavigate } from 'react-router-dom';
import { createContext } from '../components/Context';
@ -188,43 +188,61 @@ const reducer = (state: HistoryState, action: HistoryActions) =>
/* -------------------------------------------------------------------------------------------------
* BackButton
* -----------------------------------------------------------------------------------------------*/
interface BackButtonProps extends Pick<LinkProps, 'disabled'> {}
interface BackButtonProps extends Pick<LinkProps, 'disabled'> {
fallback?: To;
}
/**
* @beta
* @description The universal back button for the Strapi application. This uses the internal history
* context to navigate the user back to the previous location. It can be completely disabled in a
* specific user case.
* specific user case. When no history is available, you can provide a fallback destination,
* otherwise the link will be disabled.
*/
const BackButton = React.forwardRef<HTMLAnchorElement, BackButtonProps>(({ disabled }, ref) => {
const { formatMessage } = useIntl();
const BackButton = React.forwardRef<HTMLAnchorElement, BackButtonProps>(
({ disabled, fallback = '' }, ref) => {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const canGoBack = useHistory('BackButton', (state) => state.canGoBack);
const goBack = useHistory('BackButton', (state) => state.goBack);
const history = useHistory('BackButton', (state) => state.history);
const canGoBack = useHistory('BackButton', (state) => state.canGoBack);
const goBack = useHistory('BackButton', (state) => state.goBack);
const history = useHistory('BackButton', (state) => state.history);
const hasFallback = fallback !== '';
const shouldBeDisabled = disabled || (!canGoBack && !hasFallback);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
goBack();
};
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
return (
<Link
ref={ref}
tag={NavLink}
to={history.at(-1) ?? ''}
onClick={handleClick}
disabled={disabled || !canGoBack}
aria-disabled={disabled || !canGoBack}
startIcon={<ArrowLeft />}
>
{formatMessage({
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>
);
});
if (canGoBack) {
goBack();
} else if (hasFallback) {
navigate(fallback);
}
};
// The link destination from the history. Undefined if there is only 1 location in the history.
const historyTo = canGoBack ? history.at(-1) : undefined;
// If no link destination from the history, use the fallback.
const toWithFallback = historyTo ?? fallback;
return (
<Link
ref={ref}
tag={NavLink}
to={toWithFallback}
onClick={handleClick}
disabled={shouldBeDisabled}
aria-disabled={shouldBeDisabled}
startIcon={<ArrowLeft />}
>
{formatMessage({
id: 'global.back',
defaultMessage: 'Back',
})}
</Link>
);
}
);
export { BackButton, HistoryProvider, useHistory };
export type { BackButtonProps, HistoryProviderProps, HistoryContextValue, HistoryState };

View File

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { render as renderRTL, screen, waitFor } from '@tests/utils';
import { NavLink, useLocation } from 'react-router-dom';
import { BackButton, HistoryProvider } from '../BackButton';
import { BackButton, type BackButtonProps, HistoryProvider } from '../BackButton';
const LocationDisplay = () => {
const location = useLocation();
@ -19,8 +19,8 @@ const RandomNavLink = () => {
return <NavLink to={to}>Navigate</NavLink>;
};
const render = () =>
renderRTL(<BackButton />, {
const render = (props: BackButtonProps = {}) =>
renderRTL(<BackButton {...props} />, {
renderOptions: {
wrapper({ children }) {
return (
@ -35,12 +35,18 @@ const render = () =>
});
describe('BackButton', () => {
it('should be disabled if there is no history', () => {
it('should be disabled if there is no history and no fallback', () => {
render();
expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute('aria-disabled', 'true');
});
it('should be enabled if there is a fallback', () => {
render({ fallback: '..' });
expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute('aria-disabled', 'false');
});
it('should be enabled if there is history', async () => {
const { user } = render();

View File

@ -220,7 +220,7 @@ const CreatePage = () => {
id: 'Settings.roles.create.description',
defaultMessage: 'Define the rights given to the role',
})}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback="../roles" />}
/>
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={6}>

View File

@ -211,7 +211,7 @@ const EditPage = () => {
id: 'Settings.roles.create.description',
defaultMessage: 'Define the rights given to the role',
})}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback="../roles" />}
/>
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={6}>

View File

@ -205,7 +205,7 @@ const EditPage = () => {
name: getDisplayName(initialData),
}
)}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback="../users" />}
/>
<Layouts.Content>
{user?.registrationToken && (

View File

@ -125,7 +125,7 @@ const WebhookForm = ({
})
: data?.name
}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback="../webhooks" />}
/>
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={4}>

View File

@ -19,7 +19,7 @@ import {
} from '@strapi/design-system';
import { ListPlus, Pencil, Trash, WarningCircle } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useMatch, useNavigate } from 'react-router-dom';
import { useMatch, useNavigate, useParams } from 'react-router-dom';
import { RelativeTime } from '../../../components/RelativeTime';
import {
@ -30,7 +30,7 @@ import {
UPDATED_AT_ATTRIBUTE_NAME,
UPDATED_BY_ATTRIBUTE_NAME,
} from '../../../constants/attributes';
import { SINGLE_TYPES } from '../../../constants/collections';
import { COLLECTION_TYPES, SINGLE_TYPES } from '../../../constants/collections';
import { useDocumentRBAC } from '../../../features/DocumentRBAC';
import { useDoc } from '../../../hooks/useDocument';
import { useDocumentActions } from '../../../hooks/useDocumentActions';
@ -55,6 +55,7 @@ interface HeaderProps {
const Header = ({ isCreating, status, title: documentTitle = 'Untitled' }: HeaderProps) => {
const { formatMessage } = useIntl();
const isCloning = useMatch(CLONE_PATH) !== null;
const params = useParams<{ collectionType: string; slug: string }>();
const title = isCreating
? formatMessage({
@ -65,7 +66,13 @@ const Header = ({ isCreating, status, title: documentTitle = 'Untitled' }: Heade
return (
<Flex direction="column" alignItems="flex-start" paddingTop={6} paddingBottom={4} gap={2}>
<BackButton />
<BackButton
fallback={
params.collectionType === SINGLE_TYPES
? undefined
: `../${COLLECTION_TYPES}/${params.slug}`
}
/>
<Flex width="100%" justifyContent="space-between" gap="80px" alignItems="flex-start">
<Typography variant="alpha" tag="h1">
{title}

View File

@ -1,7 +1,9 @@
import { useForm, BackButton, Layouts } from '@strapi/admin/strapi-admin';
import { Button } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { COLLECTION_TYPES } from '../../../constants/collections';
import { capitalise } from '../../../utils/strings';
import { getTranslation } from '../../../utils/translations';
@ -13,13 +15,14 @@ interface HeaderProps {
const Header = ({ name }: HeaderProps) => {
const { formatMessage } = useIntl();
const params = useParams<{ slug: string }>();
const modified = useForm('Header', (state) => state.modified);
const isSubmitting = useForm('Header', (state) => state.isSubmitting);
return (
<Layouts.Header
navigationAction={<BackButton />}
navigationAction={<BackButton fallback={`../${COLLECTION_TYPES}/${params.slug}`} />}
primaryAction={
<Button size="S" disabled={!modified} type="submit" loading={isSubmitting}>
{formatMessage({ id: 'global.save', defaultMessage: 'Save' })}

View File

@ -271,7 +271,7 @@ const ReleaseDetailsLayout = ({
<Badge {...getBadgeProps(release.status)}>{release.status}</Badge>
</Flex>
}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback=".." />}
primaryAction={
!release.releasedAt && (
<Flex gap={2}>

View File

@ -347,7 +347,7 @@ const EditPage = () => {
{({ modified, isSubmitting, values, setErrors }) => (
<>
<Layout.Header
navigationAction={<BackButton />}
navigationAction={<BackButton fallback=".." />}
primaryAction={
canUpdate || canCreate ? (
<Button

View File

@ -119,7 +119,7 @@ export const EditPage = () => {
}
title={role.name}
subtitle={role.description}
navigationAction={<BackButton />}
navigationAction={<BackButton fallback=".." />}
/>
<Layouts.Content>
<Flex