fix(content-releases): add edit entry to details view (#19161)

Co-authored-by: Mark Kaylor <mark.kaylor@strapi.io>
This commit is contained in:
Fernando Chávez 2024-01-16 15:17:59 +01:00 committed by GitHub
parent 0e9bc9835c
commit c9259c6680
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 194 additions and 52 deletions

View File

@ -356,11 +356,12 @@ export const CMReleasesContainer = () => {
<Typography fontSize={2} fontWeight="bold" variant="omega" textColor="neutral700">
{release.name}
</Typography>
<ReleaseActionMenu
releaseId={release.id}
actionId={release.action.id}
hasTriggerBorder
/>
<ReleaseActionMenu.Root hasTriggerBorder>
<ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id}
actionId={release.action.id}
/>
</ReleaseActionMenu.Root>
</Flex>
</Flex>
);

View File

@ -1,55 +1,69 @@
import { Flex, IconButton, Typography } from '@strapi/design-system';
import { Menu } from '@strapi/design-system/v2';
import * as React from 'react';
import { Flex, IconButton, Typography, Icon } from '@strapi/design-system';
import { Menu, Link } from '@strapi/design-system/v2';
import { CheckPermissions, useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
import { Cross, More } from '@strapi/icons';
import { Cross, More, Pencil } from '@strapi/icons';
import { isAxiosError } from 'axios';
import { useIntl } from 'react-intl';
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
import { DeleteReleaseAction } from '../../../shared/contracts/release-actions';
import { DeleteReleaseAction, ReleaseAction } from '../../../shared/contracts/release-actions';
import { PERMISSIONS } from '../constants';
import { useDeleteReleaseActionMutation } from '../services/release';
const StyledMenuItem = styled(Menu.Item)`
import type { Store } from '@strapi/admin/strapi-admin';
import type { Permission } from '@strapi/helper-plugin';
type RootState = ReturnType<Store['getState']>;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const StyledMenuItem = styled(Menu.Item)<{ variant?: 'neutral' | 'danger' }>`
&:hover {
background: ${({ theme }) => theme.colors.danger100};
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};
svg {
path {
fill: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}600`]};
}
}
a {
color: ${({ theme }) => theme.colors.neutral800};
}
}
svg {
path {
fill: ${({ theme }) => theme.colors.danger600};
fill: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}600`]};
}
}
&:hover {
svg {
path {
fill: ${({ theme }) => theme.colors.danger600};
}
}
a {
color: ${({ theme }) => theme.colors.neutral800};
}
span,
a {
width: 100%;
}
`;
const StyledCross = styled(Cross)`
padding: ${({ theme }) => theme.spaces[1]};
`;
/* -------------------------------------------------------------------------------------------------
* DeleteReleaseActionItemProps
* -----------------------------------------------------------------------------------------------*/
const StyledIconButton = styled(IconButton)`
/* Setting this style inline with borderColor will not apply the style */
border: ${({ theme }) => `1px solid ${theme.colors.neutral200}`};
`;
interface ReleaseActionMenuProps {
interface DeleteReleaseActionItemProps {
releaseId: DeleteReleaseAction.Request['params']['releaseId'];
actionId: DeleteReleaseAction.Request['params']['actionId'];
hasTriggerBorder?: boolean;
}
export const ReleaseActionMenu = ({
releaseId,
actionId,
hasTriggerBorder = false,
}: ReleaseActionMenuProps) => {
const DeleteReleaseActionItem = ({ releaseId, actionId }: DeleteReleaseActionItemProps) => {
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
@ -90,6 +104,97 @@ export const ReleaseActionMenu = ({
}
};
return (
<CheckPermissions permissions={PERMISSIONS.deleteAction}>
<StyledMenuItem variant="danger" onSelect={handleDeleteAction}>
<Flex gap={2}>
<Icon as={Cross} padding={1} />
<Typography textColor="danger600" variant="omega">
{formatMessage({
id: 'content-releases.content-manager-edit-view.remove-from-release',
defaultMessage: 'Remove from release',
})}
</Typography>
</Flex>
</StyledMenuItem>
</CheckPermissions>
);
};
/* -------------------------------------------------------------------------------------------------
* ReleaseActionEntryLinkItem
* -----------------------------------------------------------------------------------------------*/
interface ReleaseActionEntryLinkItemProps {
contentTypeUid: ReleaseAction['contentType'];
entryId: ReleaseAction['entry']['id'];
locale: ReleaseAction['locale'];
}
const ReleaseActionEntryLinkItem = ({
contentTypeUid,
entryId,
locale,
}: ReleaseActionEntryLinkItemProps) => {
const { formatMessage } = useIntl();
// Confirm user has permissions to access the entry for the given locale
const collectionTypePermissions = useTypedSelector(
(state) => state.rbacProvider.collectionTypesRelatedPermissions
);
const updatePermissions = contentTypeUid
? collectionTypePermissions[contentTypeUid]?.['plugin::content-manager.explorer.update']
: [];
const canUpdateEntryForLocale = Boolean(
!locale ||
updatePermissions?.find((permission: Permission) =>
permission.properties?.locales?.includes(locale)
)
);
return (
<CheckPermissions
permissions={[
{
action: 'plugin::content-manager.explorer.update',
subject: contentTypeUid,
},
]}
>
{canUpdateEntryForLocale && (
<StyledMenuItem>
<Link
as={NavLink}
// @ts-expect-error TODO: This component from DS is not using types from NavLink
to={{
pathname: `/content-manager/collection-types/${contentTypeUid}/${entryId}`,
search: locale && `?plugins[i18n][locale]=${locale}`,
}}
startIcon={<Icon as={Pencil} padding={1} />}
>
<Typography variant="omega">
{formatMessage({
id: 'content-releases.content-manager-edit-view.edit-entry',
defaultMessage: 'Edit entry',
})}
</Typography>
</Link>
</StyledMenuItem>
)}
</CheckPermissions>
);
};
/* -------------------------------------------------------------------------------------------------
* Root
* -----------------------------------------------------------------------------------------------*/
interface RootProps {
children: React.ReactNode;
hasTriggerBorder?: boolean;
}
const Root = ({ children, hasTriggerBorder = false }: RootProps) => {
const { formatMessage } = useIntl();
return (
// A user can access the dropdown if they have permissions to delete a release-action OR update a release
<CheckPermissions permissions={[...PERMISSIONS.deleteAction, ...PERMISSIONS.update]}>
@ -115,21 +220,15 @@ export const ReleaseActionMenu = ({
Refactor this once fixed in the DS
*/}
<Menu.Content top={1} popoverPlacement="bottom-end">
<CheckPermissions permissions={PERMISSIONS.deleteAction}>
<StyledMenuItem onSelect={handleDeleteAction}>
<Flex gap={2}>
<StyledCross />
<Typography textColor="danger600" variant="omega">
{formatMessage({
id: 'content-releases.content-manager-edit-view.remove-from-release',
defaultMessage: 'Remove from release',
})}
</Typography>
</Flex>
</StyledMenuItem>
</CheckPermissions>
{children}
</Menu.Content>
</Menu.Root>
</CheckPermissions>
);
};
export const ReleaseActionMenu = {
Root,
DeleteReleaseActionItem,
ReleaseActionEntryLinkItem,
};

View File

@ -10,12 +10,22 @@ jest.mock('@strapi/helper-plugin', () => ({
describe('ReleaseActionMenu', () => {
it('should render the menu with its options', async () => {
const { user } = render(<ReleaseActionMenu releaseId="1" actionId="1" />);
const { user } = render(
<ReleaseActionMenu.Root>
<ReleaseActionMenu.DeleteReleaseActionItem releaseId="1" actionId="1" />
<ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid="api::category.category"
locale="en"
entryId="1"
/>
</ReleaseActionMenu.Root>
);
const menuTrigger = screen.getByRole('button', { name: 'Release action options' });
expect(menuTrigger).toBeInTheDocument();
await user.click(menuTrigger);
expect(screen.getByRole('menuitem', { name: 'Remove from release' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Edit entry' })).toBeInTheDocument();
});
});

View File

@ -633,7 +633,7 @@ const ReleaseDetailsBody = () => {
</Table.Head>
<Table.LoadingBody />
<Table.Body>
{releaseActions[key].map(({ id, type, entry }) => (
{releaseActions[key].map(({ id, type, entry, contentType, locale }) => (
<Tr key={id}>
<Td width={'25%'}>
<Typography ellipsis>{`${
@ -680,7 +680,17 @@ const ReleaseDetailsBody = () => {
</Td>
<Td>
<Flex justifyContent="flex-end">
<ReleaseActionMenu releaseId={releaseId} actionId={id} />
<ReleaseActionMenu.Root>
<ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid={contentType}
entryId={entry.id}
locale={locale}
/>
<ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id}
actionId={id}
/>
</ReleaseActionMenu.Root>
</Flex>
</Td>
</>

View File

@ -10,6 +10,7 @@
"content-manager-edit-view.add-to-release.redirect-button": "Open the list of releases",
"content-manager-edit-view.list-releases.title": "{isPublish, select, true {Will be published in} other {Will be unpublished in}}",
"content-manager-edit-view.remove-from-release": "Remove from release",
"content-releases.content-manager-edit-view.edit-entry": "Edit entry",
"content-manager-edit-view.remove-from-release.notification.success": "Entry removed from release",
"content-manager-edit-view.release-action-menu": "Release action options",
"content-manager.notification.entry-error": "Failed to get entry data",

View File

@ -17,6 +17,19 @@ const initialState = {
conditions: [],
},
],
collectionTypesRelatedPermissions: {
'api::category.category': {
'plugin::content-manager.explorer.update': [
{
action: 'plugin::content-manager.explorer.update',
subject: 'api::category.category',
properties: {
locales: ['en'],
},
},
],
},
},
},
};

View File

@ -261,12 +261,7 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions(
contentTypeUids
);
const allLocales: Locale[] = await strapi.plugin('i18n').service('locales').find();
const allLocalesDictionary = allLocales.reduce<LocaleDictionary>((acc, locale) => {
acc[locale.code] = { name: locale.name, code: locale.code };
return acc;
}, {});
const allLocalesDictionary = await this.getLocalesDataForActions();
const formattedData = actions.map((action: ReleaseAction) => {
const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType];
@ -289,6 +284,19 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return _.groupBy(groupName)(formattedData);
},
async getLocalesDataForActions() {
if (!strapi.plugin('i18n')) {
return {};
}
const allLocales: Locale[] = (await strapi.plugin('i18n').service('locales').find()) || [];
return allLocales.reduce<LocaleDictionary>((acc, locale) => {
acc[locale.code] = { name: locale.name, code: locale.code };
return acc;
}, {});
},
async getContentTypesDataForActions(contentTypesUids: ReleaseAction['contentType'][]) {
const contentManagerContentTypeService = strapi
.plugin('content-manager')