mirror of
https://github.com/strapi/strapi.git
synced 2025-12-28 07:33:17 +00:00
Merge pull request #17477 from strapi/feature/rw-stage-default-sort
Enhancement: Improve content-manager sorting & filtering capabilities
This commit is contained in:
commit
b2a69649be
@ -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]');
|
||||
});
|
||||
});
|
||||
|
||||
112
api-tests/core/content-manager/fields/non-visible.test.api.js
Normal file
112
api-tests/core/content-manager/fields/non-visible.test.api.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: '',
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
@ -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 : []),
|
||||
];
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
),
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 };
|
||||
@ -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';
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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: '',
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user