Merge pull request #17477 from strapi/feature/rw-stage-default-sort

Enhancement: Improve content-manager sorting & filtering capabilities
This commit is contained in:
Gustav Hansen 2023-08-23 13:55:35 +02:00 committed by GitHub
commit b2a69649be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1123 additions and 125 deletions

View File

@ -1,6 +1,7 @@
'use strict';
// Helpers.
const { set } = require('lodash/fp');
const { createTestBuilder } = require('api-tests/builder');
const { createStrapiInstance } = require('api-tests/strapi');
const form = require('api-tests/generators');
@ -16,9 +17,16 @@ const restart = async () => {
rq = await createAuthRequest({ strapi });
};
// Set a new attribute to form.article
const ct = set('attributes.nonVisible', {
type: 'string',
visible: false,
writable: true,
})(form.article);
describe('Content Manager - Configuration', () => {
beforeAll(async () => {
await builder.addContentTypes([form.article]).build();
await builder.addContentTypes([ct]).build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
@ -148,4 +156,44 @@ describe('Content Manager - Configuration', () => {
]);
expect(body.data.contentType.layouts.list).toStrictEqual(['id', 'title', 'author']);
});
test('Set non visible attribute as default sort', async () => {
// Get current config
const { body } = await rq({
url: '/content-manager/content-types/api::article.article/configuration',
method: 'GET',
});
// set default sort
const configuration = set('contentType.settings.defaultSortBy', 'nonVisible', body.data);
const res = await rq({
url: '/content-manager/content-types/api::article.article/configuration',
method: 'PUT',
body: { settings: configuration.contentType.settings },
});
expect(res.statusCode).toBe(200);
expect(res.body.data.contentType.settings.defaultSortBy).toBe('nonVisible');
});
test('Set relational attribute as default sort', async () => {
// Get current config
const { body } = await rq({
url: '/content-manager/content-types/api::article.article/configuration',
method: 'GET',
});
// set default sort
const configuration = set('contentType.settings.defaultSortBy', 'author[username]', body.data);
const res = await rq({
url: '/content-manager/content-types/api::article.article/configuration',
method: 'PUT',
body: { settings: configuration.contentType.settings },
});
expect(res.statusCode).toBe(200);
expect(res.body.data.contentType.settings.defaultSortBy).toBe('author[username]');
});
});

View File

@ -0,0 +1,112 @@
/**
* This test will use non visible fields and validate they can be filtered and sorted by
*/
'use strict';
const { createTestBuilder } = require('api-tests/builder');
const { createStrapiInstance } = require('api-tests/strapi');
const { createAuthRequest } = require('api-tests/request');
const { createUtils } = require('api-tests/utils');
const builder = createTestBuilder();
const ct = {
displayName: 'nonvisible',
singularName: 'nonvisible',
pluralName: 'nonvisibles',
attributes: {
field: {
type: 'string',
visible: false,
writable: true,
},
name: {
type: 'string',
},
},
};
let rq1;
let rq2;
let user1;
let user2;
let strapi;
let utils;
const createEntry = async (data) => {
return strapi.entityService.create('api::nonvisible.nonvisible', {
data,
});
};
/**
* == Test Suite Overview ==
*
* N° Description
* -------------------------------------------
* 1. Filters by non visible field (successfully)
* 2. Filters by created_by (successfully)
* 3. Filters by updated_by (successfully)
*/
describe('Test non visible fields', () => {
beforeAll(async () => {
await builder.addContentType(ct).build();
strapi = await createStrapiInstance();
utils = createUtils(strapi);
const userInfo = {
email: 'test@strapi.io',
firstname: 'test',
lastname: 'strapi',
registrationToken: 'foobar',
roles: [await utils.getSuperAdminRole()],
};
user1 = await utils.createUser(userInfo);
user2 = await utils.createUser({ ...userInfo, email: 'test2@strapi.io' });
rq1 = await createAuthRequest({ strapi, userInfo: user1 });
rq2 = await createAuthRequest({ strapi, userInfo: user2 });
await createEntry({ field: 'entry1', createdBy: user1.id, updatedBy: user1.id });
await createEntry({ field: 'entry2', createdBy: user2.id, updatedBy: user2.id });
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
test('User can filter by non visible and writable fields ', async () => {
const res = await rq1.get(
`/content-manager/collection-types/api::nonvisible.nonvisible?filters[field][$eq]=entry1`
);
expect(res.statusCode).toBe(200);
expect(res.body.results.length).toBe(1);
expect(res.body.results[0].field).toBe('entry1');
});
test('User can filter by createdBy field ', async () => {
const res = await rq1.get(
`/content-manager/collection-types/api::nonvisible.nonvisible?filters[createdBy][id][$eq]=${user1.id}`
);
expect(res.statusCode).toBe(200);
expect(res.body.results.length).toBe(1);
expect(res.body.results[0].createdBy.id).toBe(user1.id);
});
test('User can filter by updatedBy field ', async () => {
const res = await rq1.get(
`/content-manager/collection-types/api::nonvisible.nonvisible?filters[updatedBy][id][$eq]=${user1.id}`
);
expect(res.statusCode).toBe(200);
expect(res.body.results.length).toBe(1);
expect(res.body.results[0].updatedBy.id).toBe(user1.id);
});
});

View File

@ -1,42 +0,0 @@
import { findMatchingPermissions, useRBACProvider } from '@strapi/helper-plugin';
import get from 'lodash/get';
const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password'];
const TIMESTAMPS = ['createdAt', 'updatedAt'];
const useAllowedAttributes = (contentType, slug) => {
const { allPermissions } = useRBACProvider();
const readPermissionsForSlug = findMatchingPermissions(allPermissions, [
{
action: 'plugin::content-manager.explorer.read',
subject: slug,
},
]);
const readPermissionForAttr = get(readPermissionsForSlug, ['0', 'properties', 'fields'], []);
const attributesArray = Object.keys(get(contentType, ['attributes']), {});
const allowedAttributes = attributesArray
.filter((attr) => {
const current = get(contentType, ['attributes', attr], {});
if (!current.type) {
return false;
}
if (NOT_ALLOWED_FILTERS.includes(current.type)) {
return false;
}
if (!readPermissionForAttr.includes(attr) && attr !== 'id' && !TIMESTAMPS.includes(attr)) {
return false;
}
return true;
})
.sort();
return allowedAttributes;
};
export default useAllowedAttributes;

View File

@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import Filters from './Filters';
import useAllowedAttributes from './hooks/useAllowedAttributes';
const AttributeFilter = ({ contentType, slug, metadatas }) => {
const { formatMessage } = useIntl();
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;
return {
name,
metadatas: { label: formatMessage({ id: label, defaultMessage: label }) },
fieldSchema: { type, options, mainField },
trackedEvent,
};
});
return <Filters displayedFilters={displayedFilters} />;
};
AttributeFilter.propTypes = {
contentType: PropTypes.object.isRequired,
metadatas: PropTypes.object.isRequired,
slug: PropTypes.string.isRequired,
};
export default AttributeFilter;

View File

@ -239,7 +239,14 @@ const reducer = (state, action) =>
(value) => {
return value.type === 'relation';
},
(_, { path }) => {
(value, { path }) => {
const relationFieldName = path[path.length - 1];
// When editing, we don't want to fetch the relations with creator fields because we already have it
if (value && (relationFieldName === 'createdBy' || relationFieldName === 'updatedBy')) {
return value;
}
if (state.modifiedData?.id === data.id && get(state.modifiedData, path)) {
return get(state.modifiedData, path);
}

View File

@ -0,0 +1,42 @@
import React from 'react';
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';
export const AdminUsersFilter = ({ value, onChange }) => {
const { formatMessage } = useIntl();
const { users, isLoading } = useAdminUsers();
return (
<Combobox
value={value}
aria-label={formatMessage({
id: 'content-manager.components.Filters.usersSelect.label',
defaultMessage: 'Search and select an user to filter',
})}
onChange={onChange}
loading={isLoading}
>
{users.map((user) => {
return (
<ComboboxOption key={user.id} value={user.id.toString()}>
{getDisplayName(user, formatMessage)}
</ComboboxOption>
);
})}
</Combobox>
);
};
AdminUsersFilter.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
AdminUsersFilter.defaultProps = {
value: '',
};

View File

@ -0,0 +1,84 @@
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 { AdminUsersFilter } from '../AdminUsersFilter';
const server = setupServer(
rest.get('*/admin/users', (req, res, ctx) => {
const mockUsers = [
{ id: 1, firstname: 'John', lastname: 'Doe' },
{ id: 2, firstname: 'Kai', lastname: 'Doe' },
];
return res(
ctx.json({
data: {
results: mockUsers,
},
})
);
})
);
const queryClient = new QueryClient();
const setup = (props) => {
return {
...render(<AdminUsersFilter {...props} />, {
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</QueryClientProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
};
};
describe('AdminUsersFilter', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('should render all the options fetched from the API', async () => {
const mockOnChange = jest.fn();
const { getByText, user, getByRole } = setup({ onChange: mockOnChange });
await user.click(getByRole('combobox'));
await waitFor(() => {
expect(getByText('John Doe')).toBeInTheDocument();
expect(getByText('Kai Doe')).toBeInTheDocument();
});
});
it('should call the onChange function with the selected value', async () => {
const mockOnChange = jest.fn();
const { getByText, user, getByRole } = setup({ onChange: mockOnChange });
await user.click(getByRole('combobox'));
await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument());
const option = getByText('John Doe');
await user.click(option);
expect(mockOnChange).toHaveBeenCalledWith('1');
});
});

View File

@ -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;

View File

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

View File

@ -0,0 +1,47 @@
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'];
export const useAllowedAttributes = (contentType, slug) => {
const { allPermissions } = useRBACProvider();
const readPermissionsForSlug = findMatchingPermissions(allPermissions, [
{
action: 'plugin::content-manager.explorer.read',
subject: slug,
},
]);
const canReadAdminUsers =
findMatchingPermissions(allPermissions, [
{
action: 'admin::users.read',
subject: null,
},
]).length > 0;
const attributesWithReadPermissions = readPermissionsForSlug?.[0]?.properties?.fields ?? [];
const allowedAttributes = attributesWithReadPermissions.filter((attr) => {
const current = contentType?.attributes?.[attr] ?? {};
if (!current.type) {
return false;
}
if (NOT_ALLOWED_FILTERS.includes(current.type)) {
return false;
}
return true;
});
return [
'id',
...allowedAttributes,
...TIMESTAMPS,
...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []),
];
};

View File

@ -5,8 +5,7 @@ import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { getFullName } from '../../../../utils/getFullName';
import { getTrad } from '../../../utils';
import { getTrad, getDisplayName } from '../../../utils';
import getUnits from './utils/getUnits';
@ -42,9 +41,13 @@ const KeyValuePair = ({ label, value }) => {
);
};
KeyValuePair.defaultProps = {
value: '-',
};
KeyValuePair.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
value: PropTypes.string,
};
const Body = () => {
@ -53,18 +56,16 @@ const Body = () => {
const currentTime = useRef(Date.now());
const getFieldInfo = (atField, byField) => {
const { firstname, lastname, username } = initialData[byField] ?? {};
const user = initialData[byField] ?? {};
const userFirstname = firstname ?? '';
const userLastname = lastname ?? '';
const user = username ?? getFullName(userFirstname, userLastname);
const displayName = getDisplayName(user, formatMessage);
const timestamp = initialData[atField] ? new Date(initialData[atField]).getTime() : Date.now();
const elapsed = timestamp - currentTime.current;
const { unit, value } = getUnits(-elapsed);
return {
at: formatRelativeTime(value, unit, { numeric: 'auto' }),
by: isCreatingEntry ? '-' : user,
by: isCreatingEntry ? '-' : displayName,
};
};

View File

@ -10,14 +10,36 @@ import {
ToggleInput,
Typography,
} from '@strapi/design-system';
import { useCollator } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useEnterprise } from '../../../../hooks/useEnterprise';
import { getTrad } from '../../../utils';
export const Settings = ({ modifiedData, onChange, sortOptions }) => {
const { formatMessage } = useIntl();
const { settings, metadatas } = modifiedData;
export const Settings = ({ contentTypeOptions, modifiedData, onChange, sortOptions: sortOptionsCE }) => {
const { formatMessage, locale } = useIntl();
const formatter = useCollator(locale, {
sensitivity: 'base',
});
const sortOptions = useEnterprise(
sortOptionsCE,
async () =>
(await import('../../../../../../ee/admin/content-manager/pages/ListSettingsView/constants'))
.REVIEW_WORKFLOW_STAGE_SORT_OPTION_NAME,
{
combine(ceOptions, eeOption) {
return [...ceOptions, { ...eeOption, label: formatMessage(eeOption.label) }];
},
defaultValue: sortOptionsCE,
enabled: !!contentTypeOptions?.reviewWorkflows,
}
);
const sortOptionsSorted = sortOptions.sort((a, b) => formatter.compare(a.label, b.label));
const { settings } = modifiedData;
return (
<Flex direction="column" alignItems="stretch" gap={4}>
@ -129,9 +151,9 @@ export const Settings = ({ modifiedData, onChange, sortOptions }) => {
name="settings.defaultSortBy"
value={modifiedData.settings.defaultSortBy || ''}
>
{sortOptions.map((sortBy) => (
<Option key={sortBy} value={sortBy}>
{metadatas[sortBy].list.label || sortBy}
{sortOptionsSorted.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
@ -164,7 +186,13 @@ Settings.defaultProps = {
};
Settings.propTypes = {
contentTypeOptions: PropTypes.object.isRequired,
modifiedData: PropTypes.object,
onChange: PropTypes.func.isRequired,
sortOptions: PropTypes.array,
sortOptions: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
}).isRequired
),
};

View File

@ -47,7 +47,7 @@ export const ListSettingsView = ({ layout, slug }) => {
const isModalFormOpen = Object.keys(fieldForm).length !== 0;
const { attributes } = layout;
const { attributes, options } = layout;
const displayedFields = modifiedData.layouts.list;
const goBackUrl = () => {
@ -169,7 +169,10 @@ export const ListSettingsView = ({ layout, slug }) => {
const sortOptions = Object.entries(attributes)
.filter(([, attribute]) => !EXCLUDED_SORT_ATTRIBUTE_TYPES.includes(attribute.type))
.map(([name]) => name);
.map(([name]) => ({
value: name,
label: layout.metadatas[name].list.label,
}));
const move = (originalIndex, atIndex) => {
dispatch({
@ -225,6 +228,7 @@ export const ListSettingsView = ({ layout, slug }) => {
paddingRight={7}
>
<Settings
contentTypeOptions={options}
modifiedData={modifiedData}
onChange={handleChange}
sortOptions={sortOptions}

View File

@ -17,6 +17,7 @@ import {
lightTheme,
} from '@strapi/design-system';
import {
findMatchingPermissions,
NoPermissions,
CheckPermissions,
SearchURLQuery,
@ -28,6 +29,7 @@ import {
useTracking,
Link,
useAPIErrorHandler,
useCollator,
useStrapiApp,
Table,
PaginationURLQuery,
@ -46,11 +48,14 @@ 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 { getTrad } from '../../utils';
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';
import { Body } from './components/Body';
@ -70,6 +75,9 @@ 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'];
const USER_FILTER_ATTRIBUTES = [...CREATOR_ATTRIBUTES, 'strapi_assignee'];
function ListView({
canCreate,
@ -95,23 +103,107 @@ function ListView({
const [isConfirmDeleteRowOpen, setIsConfirmDeleteRowOpen] = React.useState(false);
const toggleNotification = useNotification();
const { trackUsage } = useTracking();
const { refetchPermissions } = useRBACProvider();
const { allPermissions, refetchPermissions } = useRBACProvider();
const trackUsageRef = React.useRef(trackUsage);
const fetchPermissionsRef = React.useRef(refetchPermissions);
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;
// TODO: strapi_assignee should not be in here and rather defined
// in the ee directory.
if (USER_FILTER_ATTRIBUTES.includes(key) && !acc.includes(id)) {
acc.push(id);
}
return acc;
}, []) ?? [];
const { users, isLoading: isLoadingAdminUsers } = useAdminUsers(
{ filter: { id: { in: selectedUserIds } } },
{
// fetch the list of admin users only if the filter contains users and the
// current user has permissions to display users
enabled:
selectedUserIds.length > 0 &&
findMatchingPermissions(allPermissions, [
{
action: 'admin::users.read',
subject: null,
},
]).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 +234,68 @@ function ListView({
}
);
const reviewWorkflowFilter = useEnterprise(
REVIEW_WORKFLOW_FILTER_CE,
async () =>
(
await import(
'../../../../../ee/admin/content-manager/components/Filter/CustomInputs/ReviewWorkflows/constants'
)
).REVIEW_WORKFLOW_FILTERS,
{
combine(ceFilters, eeFilters) {
return [
...ceFilters,
...eeFilters
.filter((eeFilter) => {
// do not display the filter at all, if the current user does
// not have permissions to read admin users
if (eeFilter.name === 'strapi_assignee') {
return (
findMatchingPermissions(allPermissions, [
{
action: 'admin::users.read',
subject: null,
},
]).length > 0
);
}
return true;
})
.map((eeFilter) => ({
...eeFilter,
metadatas: {
...eeFilter.metadatas,
// the stage filter needs the current content-type uid to fetch
// the list of stages that can be assigned to this content-type
...(eeFilter.name === 'strapi_stage' ? { uid: contentType.uid } : {}),
// translate the filter label
label: formatMessage(eeFilter.metadatas.label),
// `options` allows the filter-tag to render the displayname
// of a user over a plain id
options:
eeFilter.name === 'strapi_assignee' &&
users.map((user) => ({
label: getDisplayName(user, formatMessage),
customValue: user.id.toString(),
})),
},
})),
];
},
defaultValue: [],
// we have to wait for admin users to be fully loaded, because otherwise
// combine is called to early and does not contain the latest state of
// the users array
enabled: hasReviewWorkflows && !isLoadingAdminUsers,
}
);
const { post, del } = fetchClient;
const bulkUnpublishMutation = useMutation(
@ -550,8 +704,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)
)}
/>
)}
</>
}
@ -671,6 +829,28 @@ function ListView({
}
}
if (['createdBy', 'updatedBy'].includes(name.split('.')[0])) {
// Display the users full name
return (
<Td key={key}>
<Typography textColor="neutral800">
{getDisplayName(rowData[name.split('.')[0]], formatMessage)}
</Typography>
</Td>
);
}
if (['createdBy', 'updatedBy'].includes(name.split('.')[0])) {
// Display the users full name
return (
<Td key={key}>
<Typography textColor="neutral800">
{getDisplayName(rowData[name.split('.')[0]], formatMessage)}
</Typography>
</Td>
);
}
if (typeof cellFormatter === 'function') {
return (
<Td key={key}>{cellFormatter(rowData, { key, name, ...rest })}</Td>

View File

@ -0,0 +1,33 @@
/**
* Retrieves the display name of an admin panel user
* @typedef AdminUserNamesAttributes
* @property {string} firstname
* @property {string} lastname
* @property {string} username
* @property {string} email
*
* @type {(user: AdminUserNamesAttributes, formatMessage: import('react-intl').formatMessage) => string}
*/
const getDisplayName = ({ firstname, lastname, username, email }, formatMessage) => {
if (username) {
return username;
}
// firstname is not required if the user is created with a username
if (firstname) {
return formatMessage(
{
id: 'global.fullname',
defaultMessage: '{firstname} {lastname}',
},
{
firstname,
lastname,
}
).trim();
}
return email;
};
export { getDisplayName };

View File

@ -12,3 +12,4 @@ export { default as mergeMetasWithSchema } from './mergeMetasWithSchema';
export { default as removeKeyInObject } from './removeKeyInObject';
export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData';
export { default as createYupSchema } from './schema';
export { getDisplayName } from './getDisplayName';

View File

@ -678,6 +678,7 @@
"content-manager.components.FiltersPickWrapper.PluginHeader.description": "Set the conditions to apply to filter the entries",
"content-manager.components.FiltersPickWrapper.PluginHeader.title.filter": "Filters",
"content-manager.components.FiltersPickWrapper.hide": "Hide",
"content-manager.components.Filters.usersSelect.label": "Search and select a user to filter by",
"content-manager.components.LeftMenu.Search.label": "Search for a content type",
"content-manager.components.LeftMenu.collection-types": "Collection Types",
"content-manager.components.LeftMenu.single-types": "Single Types",
@ -921,6 +922,7 @@
"global.settings": "Settings",
"global.type": "Type",
"global.users": "Users",
"global.fullname": "{firstname} {lastname}",
"light": "Light",
"notification.contentType.relations.conflict": "Content type has conflicting relations",
"notification.default.title": "Information:",

View File

@ -0,0 +1,42 @@
import React from 'react';
import { Combobox, ComboboxOption } from '@strapi/design-system';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { getDisplayName } from '../../../../../../../admin/src/content-manager/utils/getDisplayName';
import { useAdminUsers } from '../../../../../../../admin/src/hooks/useAdminUsers';
export const AssigneeFilter = ({ value, onChange }) => {
const { formatMessage } = useIntl();
const { users, isLoading } = useAdminUsers();
return (
<Combobox
value={value}
aria-label={formatMessage({
id: 'content-manager.components.Filters.usersSelect.label',
defaultMessage: 'Search and select an user to filter',
})}
onChange={onChange}
loading={isLoading}
>
{users.map((user) => {
return (
<ComboboxOption key={user.id} value={user.id.toString()}>
{getDisplayName(user, formatMessage)}
</ComboboxOption>
);
})}
</Combobox>
);
};
AssigneeFilter.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
AssigneeFilter.defaultProps = {
value: '',
};

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 StageFilter = ({ 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>
);
};
StageFilter.defaultProps = {
value: '',
};
StageFilter.propTypes = {
onChange: PropTypes.func.isRequired,
uid: PropTypes.string.isRequired,
value: PropTypes.string,
};

View File

@ -0,0 +1,71 @@
import { getTrad } from '../../../../../../../admin/src/content-manager/utils';
import { AssigneeFilter } from './AssigneeFilter';
import { StageFilter } from './StageFilter';
export const REVIEW_WORKFLOW_FILTERS = [
{
fieldSchema: {
type: 'relation',
mainField: {
name: 'name',
schema: {
type: 'string',
},
},
},
metadatas: {
customInput: StageFilter,
label: {
id: getTrad(`containers.ListPage.table-headers.reviewWorkflows.stage`),
defaultMessage: 'Review stage',
},
},
name: 'strapi_stage',
},
{
fieldSchema: {
type: 'relation',
mainField: {
name: 'id',
schema: {
type: 'int',
},
},
},
metadatas: {
customInput: AssigneeFilter,
customOperators: [
{
intlLabel: {
id: 'components.FilterOptions.FILTER_TYPES.$eq',
defaultMessage: 'is',
},
value: '$eq',
},
{
intlLabel: {
id: 'components.FilterOptions.FILTER_TYPES.$ne',
defaultMessage: 'is not',
},
value: '$ne',
},
],
label: {
id: getTrad(`containers.ListPage.table-headers.reviewWorkflows.assignee.label`),
defaultMessage: 'Assignee',
},
},
name: 'strapi_assignee',
},
];

View File

@ -0,0 +1,84 @@
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 { AssigneeFilter } from '../AssigneeFilter';
const server = setupServer(
rest.get('*/admin/users', (req, res, ctx) => {
const mockUsers = [
{ id: 1, firstname: 'John', lastname: 'Doe' },
{ id: 2, firstname: 'Kai', lastname: 'Doe' },
];
return res(
ctx.json({
data: {
results: mockUsers,
},
})
);
})
);
const queryClient = new QueryClient();
const setup = (props) => {
return {
...render(<AssigneeFilter {...props} />, {
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</QueryClientProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
};
};
describe('Content-Manager | List-view | AssigneeFilter', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('should render all the options fetched from the API', async () => {
const mockOnChange = jest.fn();
const { getByText, user, getByRole } = setup({ onChange: mockOnChange });
await user.click(getByRole('combobox'));
await waitFor(() => {
expect(getByText('John Doe')).toBeInTheDocument();
expect(getByText('Kai Doe')).toBeInTheDocument();
});
});
it('should call the onChange function with the selected value', async () => {
const mockOnChange = jest.fn();
const { getByText, user, getByRole } = setup({ onChange: mockOnChange });
await user.click(getByRole('combobox'));
await waitFor(() => expect(getByText('John Doe')).toBeInTheDocument());
const option = getByText('John Doe');
await user.click(option);
expect(mockOnChange).toHaveBeenCalledWith('1');
});
});

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 { StageFilter } from '../StageFilter';
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(<StageFilter 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 | StageFilter', () => {
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');
});
});
});

View File

@ -114,7 +114,8 @@ export function StageSelect() {
*/
} else if (
limits?.[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME] &&
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10) < workflow.stages.length
parseInt(limits[CHARGEBEE_STAGES_PER_WORKFLOW_ENTITLEMENT_NAME], 10) <
workflow.stages.length
) {
setShowLimitModal('stage');
} else {

View File

@ -0,0 +1,7 @@
export const REVIEW_WORKFLOW_STAGE_SORT_OPTION_NAME = {
value: 'strapi_stage[name]',
label: {
id: 'settings.defaultSortOrder.reviewWorkflows.label',
defaultMessage: 'Review Stage',
},
};

View File

@ -97,4 +97,21 @@ describe('Permissions Manager - Sanitize', () => {
expect(result).toEqual({ c: 'Bar' });
});
});
describe('Sanitize Query', () => {
it('Removes hidden fields on filters, sort, populate and fields', async () => {
const data = {
filters: { a: 'Foo', c: 'Bar' },
sort: { a: 'asc', c: 'desc' },
populate: { a: true, c: true },
fields: ['a', 'c'],
};
const result = await sanitizeHelpers.sanitizeQuery(data, { subject: fooModel.uid });
expect(result.filters).toEqual({ c: 'Bar' });
expect(result.sort).toEqual({ c: 'desc' });
expect(result.populate).toEqual({ c: true });
expect(result.fields).toEqual([undefined, 'c']);
});
});
});

View File

@ -56,6 +56,7 @@ module.exports = ({ action, ability, model }) => {
const sanitizeFilters = pipeAsync(
traverse.traverseQueryFilters(allowedFields(permittedFields), { schema }),
traverse.traverseQueryFilters(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQueryFilters(omitHiddenFields, { schema }),
traverse.traverseQueryFilters(removePassword, { schema }),
traverse.traverseQueryFilters(
({ key, value }, { remove }) => {
@ -70,7 +71,9 @@ module.exports = ({ action, ability, model }) => {
const sanitizeSort = pipeAsync(
traverse.traverseQuerySort(allowedFields(permittedFields), { schema }),
traverse.traverseQuerySort(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQuerySort(omitHiddenFields, { schema }),
traverse.traverseQuerySort(removePassword, { schema }),
traverse.traverseQueryFilters(omitHiddenFields, { schema }),
traverse.traverseQuerySort(
({ key, attribute, value }, { remove }) => {
if (!isScalarAttribute(attribute) && isEmpty(value)) {
@ -84,11 +87,13 @@ module.exports = ({ action, ability, model }) => {
const sanitizePopulate = pipeAsync(
traverse.traverseQueryPopulate(allowedFields(permittedFields), { schema }),
traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQueryPopulate(omitHiddenFields, { schema }),
traverse.traverseQueryPopulate(removePassword, { schema })
);
const sanitizeFields = pipeAsync(
traverse.traverseQueryFields(allowedFields(permittedFields), { schema }),
traverse.traverseQueryFields(omitHiddenFields, { schema }),
traverse.traverseQueryFields(removePassword, { schema })
);
@ -256,13 +261,21 @@ module.exports = ({ action, ability, model }) => {
};
const getQueryFields = (fields = []) => {
const nonVisibleAttributes = getNonVisibleAttributes(schema);
const writableAttributes = getWritableAttributes(schema);
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
return uniq([
...fields,
...STATIC_FIELDS,
...COMPONENT_FIELDS,
...nonVisibleWritableAttributes,
CREATED_AT_ATTRIBUTE,
UPDATED_AT_ATTRIBUTE,
PUBLISHED_AT_ATTRIBUTE,
CREATED_BY_ATTRIBUTE,
UPDATED_BY_ATTRIBUTE,
]);
};

View File

@ -27,7 +27,8 @@
"dependencies": {
"@sindresorhus/slugify": "1.1.0",
"@strapi/utils": "4.12.5",
"lodash": "4.17.21"
"lodash": "4.17.21",
"qs": "6.11.1"
},
"engines": {
"node": ">=16.0.0 <=20.x.x",

View File

@ -6,6 +6,7 @@ const {
isListable,
hasEditableAttribute,
} = require('../../services/utils/configuration/attributes');
const { isValidDefaultSort } = require('../../services/utils/configuration/settings');
/**
* Creates the validation schema for content-type configurations
*/
@ -33,7 +34,12 @@ const createSettingsSchema = (schema) => {
// should be reset when the type changes
mainField: yup.string().oneOf(validAttributes.concat('id')).default('id'),
// should be reset when the type changes
defaultSortBy: yup.string().oneOf(validAttributes.concat('id')).default('id'),
defaultSortBy: yup
.string()
.test('is-valid-sort-attribute', '${path} is not a valid sort attribute', async (value) =>
isValidDefaultSort(schema, value)
)
.default('id'),
defaultSortOrder: yup.string().oneOf(['ASC', 'DESC']).default('ASC'),
})
.noUnknown();

View File

@ -35,11 +35,12 @@ module.exports = () => ({
});
const formatAttributes = (contentType) => {
const { getVisibleAttributes, getTimestamps } = contentTypesUtils;
const { getVisibleAttributes, getTimestamps, getCreatorFields } = contentTypesUtils;
// only get attributes that can be seen in the auto generated Edit view or List view
return getVisibleAttributes(contentType)
.concat(getTimestamps(contentType))
.concat(getCreatorFields(contentType))
.reduce((acc, key) => {
const attribute = contentType.attributes[key];

View File

@ -2,6 +2,13 @@
const settingsService = require('../settings');
jest.mock('@strapi/utils', () => ({
...jest.requireActual('@strapi/utils'),
traverse: {
traverseQuerySort: jest.fn((a, b, c) => c),
},
}));
describe('Configuration settings service', () => {
describe('createDefaultSettings', () => {
test('Consistent defaults', async () => {

View File

@ -1,9 +1,12 @@
'use strict';
const _ = require('lodash');
const { intersection } = require('lodash/fp');
const { contentTypes: contentTypesUtils } = require('@strapi/utils');
const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
const { getNonVisibleAttributes, getWritableAttributes } = contentTypesUtils;
const { PUBLISHED_AT_ATTRIBUTE, CREATED_BY_ATTRIBUTE, UPDATED_BY_ATTRIBUTE } =
contentTypesUtils.constants;
const NON_SORTABLES = ['component', 'json', 'media', 'richtext', 'dynamiczone'];
const SORTABLE_RELATIONS = ['oneToOne', 'manyToOne'];
@ -86,6 +89,10 @@ const isVisible = (schema, name) => {
return false;
}
if (isCreatorField(schema, name)) {
return false;
}
return true;
};
@ -108,6 +115,21 @@ const isTimestamp = (schema, name) => {
}
};
const isCreatorField = (schema, name) => {
if (!_.has(schema.attributes, name)) {
return false;
}
const creatorFields = contentTypesUtils.getCreatorFields(schema);
if (!creatorFields || !Array.isArray(creatorFields)) {
return false;
}
if (creatorFields.includes(name)) {
return true;
}
};
const isRelation = (attribute) => attribute.type === 'relation';
const hasRelationAttribute = (schema, name) => {
@ -151,6 +173,30 @@ const findFirstStringAttribute = (schema) => {
const getDefaultMainField = (schema) => findFirstStringAttribute(schema) || 'id';
/**
* Returns list of all sortable attributes for a given content type schema
* TODO V5: Refactor non visible fields to be a part of content-manager schema so we can use isSortable instead
* @param {*} schema
* @returns
*/
const getSortableAttributes = (schema) => {
const validAttributes = Object.keys(schema.attributes).filter((key) => isListable(schema, key));
const model = strapi.getModel(schema.uid);
const nonVisibleWritableAttributes = intersection(
getNonVisibleAttributes(model),
getWritableAttributes(model)
);
return [
'id',
...validAttributes,
...nonVisibleWritableAttributes,
CREATED_BY_ATTRIBUTE,
UPDATED_BY_ATTRIBUTE,
];
};
module.exports = {
isSortable,
isVisible,
@ -160,4 +206,5 @@ module.exports = {
hasEditableAttribute,
hasRelationAttribute,
getDefaultMainField,
getSortableAttributes,
};

View File

@ -1,7 +1,9 @@
'use strict';
const { isEmpty, pick, pipe, propOr } = require('lodash/fp');
const { isSortable, getDefaultMainField } = require('./attributes');
const { isEmpty, pick, pipe, propOr, isEqual } = require('lodash/fp');
const { traverse } = require('@strapi/utils');
const qs = require('qs');
const { isSortable, getDefaultMainField, getSortableAttributes } = require('./attributes');
/** General settings */
const DEFAULT_SETTINGS = {
@ -23,7 +25,29 @@ const settingsFields = [
const getModelSettings = pipe([propOr({}, 'config.settings'), pick(settingsFields)]);
async function isValidDefaultSort(schema, value) {
const parsedValue = qs.parse(value);
const omitNonSortableAttributes = ({ schema, key }, { remove }) => {
const sortableAttributes = getSortableAttributes(schema);
if (!sortableAttributes.includes(key)) {
remove(key);
}
};
const sanitizedValue = await traverse.traverseQuerySort(
omitNonSortableAttributes,
{ schema },
parsedValue
);
// If any of the keys has been removed, the sort attribute is not valid
return isEqual(parsedValue, sanitizedValue);
}
module.exports = {
isValidDefaultSort,
async createDefaultSettings(schema) {
const defaultField = getDefaultMainField(schema);
@ -46,7 +70,9 @@ module.exports = {
return {
...configuration.settings,
mainField: isSortable(schema, mainField) ? mainField : defaultField,
defaultSortBy: isSortable(schema, defaultSortBy) ? defaultSortBy : defaultField,
defaultSortBy: (await isValidDefaultSort(schema, defaultSortBy))
? defaultSortBy
: defaultField,
};
},
};

View File

@ -52,6 +52,20 @@ const getTimestamps = (model: Model) => {
return attributes;
};
const getCreatorFields = (model: Model) => {
const attributes = [];
if (has(CREATED_BY_ATTRIBUTE, model.attributes)) {
attributes.push(CREATED_BY_ATTRIBUTE);
}
if (has(UPDATED_BY_ATTRIBUTE, model.attributes)) {
attributes.push(UPDATED_BY_ATTRIBUTE);
}
return attributes;
};
const getNonWritableAttributes = (model: Model) => {
if (!model) return [];
@ -202,6 +216,7 @@ export {
getNonVisibleAttributes,
getVisibleAttributes,
getTimestamps,
getCreatorFields,
isVisibleAttribute,
hasDraftAndPublish,
getOptions,

View File

@ -7311,6 +7311,7 @@ __metadata:
"@sindresorhus/slugify": 1.1.0
"@strapi/utils": 4.12.5
lodash: 4.17.21
qs: 6.11.1
languageName: unknown
linkType: soft