Feature: Add review workflow stages to CM list-view filter

This commit is contained in:
Gustav Hansen 2023-07-25 16:36:56 +02:00
parent 12047e3083
commit 3c4f28dad7
10 changed files with 310 additions and 130 deletions

View File

@ -1,101 +0,0 @@
import React from 'react';
import { useQueryParams } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useAdminUsers } from '../../../hooks/useAdminUsers';
import { getDisplayName } from '../../utils';
import { AdminUsersFilter } from './AdminUsersFilter';
import Filters from './Filters';
import useAllowedAttributes from './hooks/useAllowedAttributes';
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
const AttributeFilter = ({ contentType, slug, metadatas }) => {
const { formatMessage } = useIntl();
const [{ query }] = useQueryParams();
// We get the users selected' ids
const selectedUsers =
query?.filters?.$and?.reduce((acc, filter) => {
const [key, value] = Object.entries(filter)[0];
const id = value.id?.$eq || value.id?.$ne;
if (CREATOR_ATTRIBUTES.includes(key) && !acc.includes(id)) {
acc.push(id);
}
return acc;
}, []) ?? [];
const { users, isLoading } = useAdminUsers(
{ filter: { id: { in: selectedUsers } } },
{
enabled: selectedUsers.length > 0,
}
);
const allowedAttributes = useAllowedAttributes(contentType, slug);
const displayedFilters = allowedAttributes.map((name) => {
const attribute = contentType.attributes[name];
const { type, enum: options } = attribute;
const trackedEvent = {
name: 'didFilterEntries',
properties: { useRelation: type === 'relation' },
};
const { mainField, label } = metadatas[name].list;
const filter = {
name,
metadatas: { label: formatMessage({ id: label, defaultMessage: label }) },
fieldSchema: { type, options, mainField },
trackedEvent,
};
if (attribute.type === 'relation' && attribute.target === 'admin::user') {
filter.metadatas = {
...filter.metadatas,
customOperators: [
{
intlLabel: { id: 'components.FilterOptions.FILTER_TYPES.$eq', defaultMessage: 'is' },
value: '$eq',
},
{
intlLabel: {
id: 'components.FilterOptions.FILTER_TYPES.$ne',
defaultMessage: 'is not',
},
value: '$ne',
},
],
customInput: AdminUsersFilter,
options: users.map((user) => ({
label: getDisplayName(user, formatMessage),
customValue: user.id.toString(),
})),
};
filter.fieldSchema.mainField = {
name: 'id',
};
}
return filter;
});
if (isLoading) {
return null;
}
return <Filters displayedFilters={displayedFilters} />;
};
AttributeFilter.propTypes = {
contentType: PropTypes.object.isRequired,
metadatas: PropTypes.object.isRequired,
slug: PropTypes.string.isRequired,
};
export default AttributeFilter;

View File

@ -4,8 +4,8 @@ import { Combobox, ComboboxOption } from '@strapi/design-system';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useAdminUsers } from '../../../hooks/useAdminUsers'; import { useAdminUsers } from '../../../../hooks/useAdminUsers';
import { getDisplayName } from '../../utils'; import { getDisplayName } from '../../../utils';
const AdminUsersFilter = ({ value, onChange }) => { const AdminUsersFilter = ({ value, onChange }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();

View File

@ -2,11 +2,11 @@ import React, { useRef, useState } from 'react';
import { Box, Button } from '@strapi/design-system'; import { Box, Button } from '@strapi/design-system';
import { FilterListURLQuery, FilterPopoverURLQuery, useTracking } from '@strapi/helper-plugin'; import { FilterListURLQuery, FilterPopoverURLQuery, useTracking } from '@strapi/helper-plugin';
import { Filter } from '@strapi/icons'; import { Filter as FilterIcon } from '@strapi/icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const Filters = ({ displayedFilters }) => { export const Filter = ({ displayedFilters }) => {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const buttonRef = useRef(); const buttonRef = useRef();
@ -25,7 +25,7 @@ const Filters = ({ displayedFilters }) => {
<Button <Button
variant="tertiary" variant="tertiary"
ref={buttonRef} ref={buttonRef}
startIcon={<Filter />} startIcon={<FilterIcon />}
onClick={handleToggle} onClick={handleToggle}
size="S" size="S"
> >
@ -45,7 +45,7 @@ const Filters = ({ displayedFilters }) => {
); );
}; };
Filters.propTypes = { Filter.propTypes = {
displayedFilters: PropTypes.arrayOf( displayedFilters: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
@ -54,5 +54,3 @@ Filters.propTypes = {
}) })
).isRequired, ).isRequired,
}; };
export default Filters;

View File

@ -0,0 +1 @@
export * from './Filter';

View File

@ -1,17 +1,11 @@
import { findMatchingPermissions, useRBACProvider, useCollator } from '@strapi/helper-plugin'; import { useRBACProvider, findMatchingPermissions } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password']; const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password'];
const TIMESTAMPS = ['createdAt', 'updatedAt']; const TIMESTAMPS = ['createdAt', 'updatedAt'];
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy']; const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
const useAllowedAttributes = (contentType, slug) => { export const useAllowedAttributes = (contentType, slug) => {
const { allPermissions } = useRBACProvider(); const { allPermissions } = useRBACProvider();
const { locale } = useIntl();
const formatter = useCollator(locale, {
sensitivity: 'base',
});
const readPermissionsForSlug = findMatchingPermissions(allPermissions, [ const readPermissionsForSlug = findMatchingPermissions(allPermissions, [
{ {
@ -43,14 +37,11 @@ const useAllowedAttributes = (contentType, slug) => {
return true; return true;
}); });
const allowedAndDefaultAttributes = [
return [
'id', 'id',
...allowedAttributes, ...allowedAttributes,
...TIMESTAMPS, ...TIMESTAMPS,
...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []), ...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []),
]; ];
return allowedAndDefaultAttributes.sort((a, b) => formatter.compare(a, b));
}; };
export default useAllowedAttributes;

View File

@ -28,6 +28,7 @@ import {
useTracking, useTracking,
Link, Link,
useAPIErrorHandler, useAPIErrorHandler,
useCollator,
useStrapiApp, useStrapiApp,
Table, Table,
PaginationURLQuery, PaginationURLQuery,
@ -46,10 +47,13 @@ import { bindActionCreators, compose } from 'redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { INJECT_COLUMN_IN_TABLE } from '../../../exposedHooks'; import { INJECT_COLUMN_IN_TABLE } from '../../../exposedHooks';
import { useAdminUsers } from '../../../hooks/useAdminUsers';
import { useEnterprise } from '../../../hooks/useEnterprise'; import { useEnterprise } from '../../../hooks/useEnterprise';
import { selectAdminPermissions } from '../../../pages/App/selectors'; import { selectAdminPermissions } from '../../../pages/App/selectors';
import { InjectionZone } from '../../../shared/components'; import { InjectionZone } from '../../../shared/components';
import AttributeFilter from '../../components/AttributeFilter'; import { Filter } from '../../components/Filter';
import { AdminUsersFilter } from '../../components/Filter/CustomInputs/AdminUsersFilter';
import { useAllowedAttributes } from '../../hooks/useAllowedAttributes';
import { getTrad, getDisplayName } from '../../utils'; import { getTrad, getDisplayName } from '../../utils';
import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions'; import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions';
@ -70,6 +74,8 @@ const ConfigureLayoutBox = styled(Box)`
const REVIEW_WORKFLOW_COLUMNS_CE = null; const REVIEW_WORKFLOW_COLUMNS_CE = null;
const REVIEW_WORKFLOW_COLUMNS_CELL_CE = () => null; const REVIEW_WORKFLOW_COLUMNS_CELL_CE = () => null;
const REVIEW_WORKFLOW_FILTER_CE = [];
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
function ListView({ function ListView({
canCreate, canCreate,
@ -101,17 +107,90 @@ function ListView({
const { notifyStatus } = useNotifyAT(); const { notifyStatus } = useNotifyAT();
const { formatAPIError } = useAPIErrorHandler(getTrad); const { formatAPIError } = useAPIErrorHandler(getTrad);
const permissions = useSelector(selectAdminPermissions); const permissions = useSelector(selectAdminPermissions);
const allowedAttributes = useAllowedAttributes(contentType, slug);
const [{ query }] = useQueryParams();
const { pathname } = useLocation();
const { push } = useHistory();
const { formatMessage, locale } = useIntl();
const fetchClient = useFetchClient();
const formatter = useCollator(locale, {
sensitivity: 'base',
});
const selectedUserIds =
query?.filters?.$and?.reduce((acc, filter) => {
const [key, value] = Object.entries(filter)[0];
const id = value.id?.$eq || value.id?.$ne;
if (CREATOR_ATTRIBUTES.includes(key) && !acc.includes(id)) {
acc.push(id);
}
return acc;
}, []) ?? [];
const { users, isLoading: isLoadingAdminUsers } = useAdminUsers(
{ filter: { id: { in: selectedUserIds } } },
{
enabled: selectedUserIds.length > 0,
}
);
useFocusWhenNavigate(); useFocusWhenNavigate();
const [{ query }] = useQueryParams();
const params = React.useMemo(() => buildValidGetParams(query), [query]); const params = React.useMemo(() => buildValidGetParams(query), [query]);
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false }); const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
const { pathname } = useLocation(); const displayedAttributeFilters = allowedAttributes.map((name) => {
const { push } = useHistory(); const attribute = contentType.attributes[name];
const { formatMessage } = useIntl(); const { type, enum: options } = attribute;
const fetchClient = useFetchClient();
const trackedEvent = {
name: 'didFilterEntries',
properties: { useRelation: type === 'relation' },
};
const { mainField, label } = metadatas[name].list;
const filter = {
name,
metadatas: { label: formatMessage({ id: label, defaultMessage: label }) },
fieldSchema: { type, options, mainField },
trackedEvent,
};
if (attribute.type === 'relation' && attribute.target === 'admin::user') {
filter.metadatas = {
...filter.metadatas,
customOperators: [
{
intlLabel: {
id: 'components.FilterOptions.FILTER_TYPES.$eq',
defaultMessage: 'is',
},
value: '$eq',
},
{
intlLabel: {
id: 'components.FilterOptions.FILTER_TYPES.$ne',
defaultMessage: 'is not',
},
value: '$ne',
},
],
customInput: AdminUsersFilter,
options: users.map((user) => ({
label: getDisplayName(user, formatMessage),
customValue: user.id.toString(),
})),
};
filter.fieldSchema.mainField = {
name: 'id',
};
}
return filter;
});
const hasDraftAndPublish = options?.draftAndPublish ?? false; const hasDraftAndPublish = options?.draftAndPublish ?? false;
const hasReviewWorkflows = options?.reviewWorkflows ?? false; const hasReviewWorkflows = options?.reviewWorkflows ?? false;
@ -142,6 +221,34 @@ function ListView({
} }
); );
const reviewWorkflowFilter = useEnterprise(
REVIEW_WORKFLOW_FILTER_CE,
async () =>
(
await import(
'../../../../../ee/admin/content-manager/components/Filter/CustomInputs/ReviewWorkflows/constants'
)
).REVIEW_WORKFLOW_STAGE_FILTER,
{
combine(ceFilters, eeFilter) {
return [
...ceFilters,
{
...eeFilter,
metadatas: {
...eeFilter.metadatas,
label: formatMessage(eeFilter.metadatas.label),
uid: contentType.uid,
},
},
];
},
defaultValue: [],
enabled: hasReviewWorkflows,
}
);
const { post, del } = fetchClient; const { post, del } = fetchClient;
const bulkUnpublishMutation = useMutation( const bulkUnpublishMutation = useMutation(
@ -550,8 +657,12 @@ function ListView({
trackedEvent="didSearch" trackedEvent="didSearch"
/> />
)} )}
{isFilterable && ( {isFilterable && !isLoadingAdminUsers && (
<AttributeFilter contentType={contentType} slug={slug} metadatas={metadatas} /> <Filter
displayedFilters={[...displayedAttributeFilters, ...reviewWorkflowFilter].sort(
(a, b) => formatter.compare(a.metadatas.label, b.metadatas.label)
)}
/>
)} )}
</> </>
} }

View File

@ -0,0 +1,70 @@
import * as React from 'react';
import { Flex, Loader, SingleSelect, SingleSelectOption, Typography } from '@strapi/design-system';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useReviewWorkflows } from '../../../../../pages/SettingsPage/pages/ReviewWorkflows/hooks/useReviewWorkflows';
import { getStageColorByHex } from '../../../../../pages/SettingsPage/pages/ReviewWorkflows/utils/colors';
export const ReviewWorkflowsFilter = ({ value, onChange, uid }) => {
const { formatMessage } = useIntl();
const {
workflows: [workflow],
isLoading,
} = useReviewWorkflows({ filters: { contentTypes: uid } });
return (
<SingleSelect
aria-label={formatMessage({
id: 'content-manager.components.Filters.reviewWorkflows.label',
defaultMessage: 'Search and select an workflow stage to filter',
})}
value={value}
onChange={onChange}
loading={isLoading}
// eslint-disable-next-line react/no-unstable-nested-components
customizeContent={() => (
<Flex as="span" justifyContent="space-between" alignItems="center" width="100%">
<Typography textColor="neutral800" ellipsis>
{value}
</Typography>
{isLoading ? <Loader small style={{ display: 'flex' }} /> : null}
</Flex>
)}
>
{(workflow?.stages ?? []).map(({ id, color, name }) => {
const { themeColorName } = getStageColorByHex(color);
return (
<SingleSelectOption
key={id}
startIcon={
<Flex
height={2}
background={color}
borderColor={themeColorName === 'neutral0' ? 'neutral150' : 'transparent'}
hasRadius
shrink={0}
width={2}
/>
}
value={name}
>
{name}
</SingleSelectOption>
);
})}
</SingleSelect>
);
};
ReviewWorkflowsFilter.defaultProps = {
value: '',
};
ReviewWorkflowsFilter.propTypes = {
onChange: PropTypes.func.isRequired,
uid: PropTypes.string.isRequired,
value: PropTypes.string,
};

View File

@ -0,0 +1,27 @@
import { getTrad } from '../../../../../../../admin/src/content-manager/utils';
import { ReviewWorkflowsFilter } from './ReviewWorkflowsFilter';
export const REVIEW_WORKFLOW_STAGE_FILTER = {
fieldSchema: {
type: 'relation',
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
metadatas: {
customInput: ReviewWorkflowsFilter,
label: {
id: getTrad(`containers.ListPage.table-headers.reviewWorkflows.stage`),
defaultMessage: 'Review stage',
},
},
name: 'strapi_stage',
};

View File

@ -0,0 +1,83 @@
import React from 'react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { IntlProvider } from 'react-intl';
import { QueryClientProvider, QueryClient } from 'react-query';
import { ReviewWorkflowsFilter } from '../ReviewWorkflowsFilter';
const server = setupServer(
rest.get('*/admin/review-workflows/workflows', (req, res, ctx) => {
return res(
ctx.json({
data: [
{
id: 1,
stages: [
{
id: 1,
name: 'To Review',
color: '#FFFFFF',
},
],
},
],
})
);
})
);
const queryClient = new QueryClient();
const setup = (props) => {
return {
...render(<ReviewWorkflowsFilter uid="api::address.address" onChange={() => {}} {...props} />, {
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</QueryClientProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
};
};
describe('Content-Manger | List View | Filter | ReviewWorkflowsFilter', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('should display stages', async () => {
const { getByText, user, getByRole } = setup();
await user.click(getByRole('combobox'));
await waitFor(() => {
expect(getByText('To Review')).toBeInTheDocument();
});
});
it('should use the stage name as filter value', async () => {
const spy = jest.fn();
const { getByText, user, getByRole } = setup({ onChange: spy });
await user.click(getByRole('combobox'));
await user.click(getByText('To Review'));
await waitFor(() => {
expect(spy).toHaveBeenCalledWith('To Review');
});
});
});