mirror of
https://github.com/strapi/strapi.git
synced 2025-09-17 12:27:33 +00:00
Feature: Add review workflow stages to CM list-view filter
This commit is contained in:
parent
12047e3083
commit
3c4f28dad7
@ -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;
|
@ -4,8 +4,8 @@ import { Combobox, ComboboxOption } from '@strapi/design-system';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { useAdminUsers } from '../../../hooks/useAdminUsers';
|
||||
import { getDisplayName } from '../../utils';
|
||||
import { useAdminUsers } from '../../../../hooks/useAdminUsers';
|
||||
import { getDisplayName } from '../../../utils';
|
||||
|
||||
const AdminUsersFilter = ({ value, onChange }) => {
|
||||
const { formatMessage } = useIntl();
|
@ -2,11 +2,11 @@ import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Box, Button } from '@strapi/design-system';
|
||||
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 { useIntl } from 'react-intl';
|
||||
|
||||
const Filters = ({ displayedFilters }) => {
|
||||
export const Filter = ({ displayedFilters }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { formatMessage } = useIntl();
|
||||
const buttonRef = useRef();
|
||||
@ -25,7 +25,7 @@ const Filters = ({ displayedFilters }) => {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
ref={buttonRef}
|
||||
startIcon={<Filter />}
|
||||
startIcon={<FilterIcon />}
|
||||
onClick={handleToggle}
|
||||
size="S"
|
||||
>
|
||||
@ -45,7 +45,7 @@ const Filters = ({ displayedFilters }) => {
|
||||
);
|
||||
};
|
||||
|
||||
Filters.propTypes = {
|
||||
Filter.propTypes = {
|
||||
displayedFilters: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
@ -54,5 +54,3 @@ Filters.propTypes = {
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default Filters;
|
@ -0,0 +1 @@
|
||||
export * from './Filter';
|
@ -1,17 +1,11 @@
|
||||
import { findMatchingPermissions, useRBACProvider, useCollator } from '@strapi/helper-plugin';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useRBACProvider, findMatchingPermissions } from '@strapi/helper-plugin';
|
||||
|
||||
const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password'];
|
||||
const TIMESTAMPS = ['createdAt', 'updatedAt'];
|
||||
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
|
||||
|
||||
const useAllowedAttributes = (contentType, slug) => {
|
||||
export const useAllowedAttributes = (contentType, slug) => {
|
||||
const { allPermissions } = useRBACProvider();
|
||||
const { locale } = useIntl();
|
||||
|
||||
const formatter = useCollator(locale, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const readPermissionsForSlug = findMatchingPermissions(allPermissions, [
|
||||
{
|
||||
@ -43,14 +37,11 @@ const useAllowedAttributes = (contentType, slug) => {
|
||||
|
||||
return true;
|
||||
});
|
||||
const allowedAndDefaultAttributes = [
|
||||
|
||||
return [
|
||||
'id',
|
||||
...allowedAttributes,
|
||||
...TIMESTAMPS,
|
||||
...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []),
|
||||
];
|
||||
|
||||
return allowedAndDefaultAttributes.sort((a, b) => formatter.compare(a, b));
|
||||
};
|
||||
|
||||
export default useAllowedAttributes;
|
@ -28,6 +28,7 @@ import {
|
||||
useTracking,
|
||||
Link,
|
||||
useAPIErrorHandler,
|
||||
useCollator,
|
||||
useStrapiApp,
|
||||
Table,
|
||||
PaginationURLQuery,
|
||||
@ -46,10 +47,13 @@ import { bindActionCreators, compose } from 'redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { INJECT_COLUMN_IN_TABLE } from '../../../exposedHooks';
|
||||
import { useAdminUsers } from '../../../hooks/useAdminUsers';
|
||||
import { useEnterprise } from '../../../hooks/useEnterprise';
|
||||
import { selectAdminPermissions } from '../../../pages/App/selectors';
|
||||
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 { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions';
|
||||
@ -70,6 +74,8 @@ const ConfigureLayoutBox = styled(Box)`
|
||||
|
||||
const REVIEW_WORKFLOW_COLUMNS_CE = null;
|
||||
const REVIEW_WORKFLOW_COLUMNS_CELL_CE = () => null;
|
||||
const REVIEW_WORKFLOW_FILTER_CE = [];
|
||||
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
|
||||
|
||||
function ListView({
|
||||
canCreate,
|
||||
@ -101,17 +107,90 @@ function ListView({
|
||||
const { notifyStatus } = useNotifyAT();
|
||||
const { formatAPIError } = useAPIErrorHandler(getTrad);
|
||||
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();
|
||||
|
||||
const [{ query }] = useQueryParams();
|
||||
const params = React.useMemo(() => buildValidGetParams(query), [query]);
|
||||
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { push } = useHistory();
|
||||
const { formatMessage } = useIntl();
|
||||
const fetchClient = useFetchClient();
|
||||
const displayedAttributeFilters = 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;
|
||||
});
|
||||
|
||||
const hasDraftAndPublish = options?.draftAndPublish ?? 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 bulkUnpublishMutation = useMutation(
|
||||
@ -550,8 +657,12 @@ function ListView({
|
||||
trackedEvent="didSearch"
|
||||
/>
|
||||
)}
|
||||
{isFilterable && (
|
||||
<AttributeFilter contentType={contentType} slug={slug} metadatas={metadatas} />
|
||||
{isFilterable && !isLoadingAdminUsers && (
|
||||
<Filter
|
||||
displayedFilters={[...displayedAttributeFilters, ...reviewWorkflowFilter].sort(
|
||||
(a, b) => formatter.compare(a.metadatas.label, b.metadatas.label)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
@ -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',
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user