Add creator fields to filters and list view

This commit is contained in:
Fernando Chavez 2023-07-27 12:00:54 +02:00 committed by Gustav Hansen
parent c020a6c78d
commit 3b7d23f061
15 changed files with 354 additions and 32 deletions

View File

@ -0,0 +1,44 @@
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';
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: '',
};
export { AdminUsersFilter };

View File

@ -1,11 +1,17 @@
import { findMatchingPermissions, useRBACProvider } from '@strapi/helper-plugin'; import { findMatchingPermissions, useRBACProvider, useCollator } from '@strapi/helper-plugin';
import get from 'lodash/get'; import { useIntl } from 'react-intl';
const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password']; const NOT_ALLOWED_FILTERS = ['json', 'component', 'media', 'richtext', 'dynamiczone', 'password'];
const TIMESTAMPS = ['createdAt', 'updatedAt']; const TIMESTAMPS = ['createdAt', 'updatedAt'];
const CREATOR_ATTRIBUTES = ['createdBy', 'updatedBy'];
const useAllowedAttributes = (contentType, slug) => { const useAllowedAttributes = (contentType, slug) => {
const { allPermissions } = useRBACProvider(); const { allPermissions } = useRBACProvider();
const { locale } = useIntl();
const formatter = useCollator(locale, {
sensitivity: 'base',
});
const readPermissionsForSlug = findMatchingPermissions(allPermissions, [ const readPermissionsForSlug = findMatchingPermissions(allPermissions, [
{ {
@ -14,29 +20,37 @@ const useAllowedAttributes = (contentType, slug) => {
}, },
]); ]);
const readPermissionForAttr = get(readPermissionsForSlug, ['0', 'properties', 'fields'], []); const canReadAdminUsers =
const attributesArray = Object.keys(get(contentType, ['attributes']), {}); findMatchingPermissions(allPermissions, [
const allowedAttributes = attributesArray {
.filter((attr) => { action: 'admin::users.read',
const current = get(contentType, ['attributes', attr], {}); subject: null,
},
]).length > 0;
if (!current.type) { const attributesWithReadPermissions = readPermissionsForSlug?.[0]?.properties?.fields ?? [];
return false;
}
if (NOT_ALLOWED_FILTERS.includes(current.type)) { const allowedAttributes = attributesWithReadPermissions.filter((attr) => {
return false; const current = contentType?.attributes?.[attr] ?? {};
}
if (!readPermissionForAttr.includes(attr) && attr !== 'id' && !TIMESTAMPS.includes(attr)) { if (!current.type) {
return false; return false;
} }
return true; if (NOT_ALLOWED_FILTERS.includes(current.type)) {
}) return false;
.sort(); }
return allowedAttributes; return true;
});
const allowedAndDefaultAttributes = [
'id',
...allowedAttributes,
...TIMESTAMPS,
...(canReadAdminUsers ? CREATOR_ATTRIBUTES : []),
];
return allowedAndDefaultAttributes.sort((a, b) => formatter.compare(a, b));
}; };
export default useAllowedAttributes; export default useAllowedAttributes;

View File

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

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

@ -239,7 +239,14 @@ const reducer = (state, action) =>
(value) => { (value) => {
return value.type === 'relation'; 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)) { if (state.modifiedData?.id === data.id && get(state.modifiedData, path)) {
return get(state.modifiedData, path); return get(state.modifiedData, path);
} }

View File

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

View File

@ -50,7 +50,7 @@ import { useEnterprise } from '../../../hooks/useEnterprise';
import { selectAdminPermissions } from '../../../pages/App/selectors'; import { selectAdminPermissions } from '../../../pages/App/selectors';
import { InjectionZone } from '../../../shared/components'; import { InjectionZone } from '../../../shared/components';
import AttributeFilter from '../../components/AttributeFilter'; import AttributeFilter from '../../components/AttributeFilter';
import { getTrad } from '../../utils'; import { getTrad, getDisplayName } from '../../utils';
import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions'; import { getData, getDataSucceeded, onChangeListHeaders, onResetListHeaders } from './actions';
import { Body } from './components/Body'; import { Body } from './components/Body';
@ -648,6 +648,17 @@ 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 (typeof cellFormatter === 'function') { if (typeof cellFormatter === 'function') {
return ( return (
<Td key={key}>{cellFormatter(rowData, { key, name, ...rest })}</Td> <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 removeKeyInObject } from './removeKeyInObject';
export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData'; export { default as removePasswordFieldsFromData } from './removePasswordFieldsFromData';
export { default as createYupSchema } from './schema'; export { default as createYupSchema } from './schema';
export { getDisplayName } from './getDisplayName';

View File

@ -671,6 +671,7 @@
"content-manager.components.FiltersPickWrapper.PluginHeader.description": "Set the conditions to apply to filter the entries", "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.PluginHeader.title.filter": "Filters",
"content-manager.components.FiltersPickWrapper.hide": "Hide", "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.Search.label": "Search for a content type",
"content-manager.components.LeftMenu.collection-types": "Collection Types", "content-manager.components.LeftMenu.collection-types": "Collection Types",
"content-manager.components.LeftMenu.single-types": "Single Types", "content-manager.components.LeftMenu.single-types": "Single Types",
@ -914,6 +915,7 @@
"global.settings": "Settings", "global.settings": "Settings",
"global.type": "Type", "global.type": "Type",
"global.users": "Users", "global.users": "Users",
"global.fullname": "{firstname} {lastname}",
"light": "Light", "light": "Light",
"notification.contentType.relations.conflict": "Content type has conflicting relations", "notification.contentType.relations.conflict": "Content type has conflicting relations",
"notification.default.title": "Information:", "notification.default.title": "Information:",

View File

@ -97,4 +97,21 @@ describe('Permissions Manager - Sanitize', () => {
expect(result).toEqual({ c: 'Bar' }); 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( const sanitizeFilters = pipeAsync(
traverse.traverseQueryFilters(allowedFields(permittedFields), { schema }), traverse.traverseQueryFilters(allowedFields(permittedFields), { schema }),
traverse.traverseQueryFilters(omitDisallowedAdminUserFields, { schema }), traverse.traverseQueryFilters(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQueryFilters(omitHiddenFields, { schema }),
traverse.traverseQueryFilters(removePassword, { schema }), traverse.traverseQueryFilters(removePassword, { schema }),
traverse.traverseQueryFilters( traverse.traverseQueryFilters(
({ key, value }, { remove }) => { ({ key, value }, { remove }) => {
@ -70,6 +71,7 @@ module.exports = ({ action, ability, model }) => {
const sanitizeSort = pipeAsync( const sanitizeSort = pipeAsync(
traverse.traverseQuerySort(allowedFields(permittedFields), { schema }), traverse.traverseQuerySort(allowedFields(permittedFields), { schema }),
traverse.traverseQuerySort(omitDisallowedAdminUserFields, { schema }), traverse.traverseQuerySort(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQuerySort(omitHiddenFields, { schema }),
traverse.traverseQuerySort(removePassword, { schema }), traverse.traverseQuerySort(removePassword, { schema }),
traverse.traverseQuerySort( traverse.traverseQuerySort(
({ key, attribute, value }, { remove }) => { ({ key, attribute, value }, { remove }) => {
@ -84,11 +86,13 @@ module.exports = ({ action, ability, model }) => {
const sanitizePopulate = pipeAsync( const sanitizePopulate = pipeAsync(
traverse.traverseQueryPopulate(allowedFields(permittedFields), { schema }), traverse.traverseQueryPopulate(allowedFields(permittedFields), { schema }),
traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }), traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQueryPopulate(omitHiddenFields, { schema }),
traverse.traverseQueryPopulate(removePassword, { schema }) traverse.traverseQueryPopulate(removePassword, { schema })
); );
const sanitizeFields = pipeAsync( const sanitizeFields = pipeAsync(
traverse.traverseQueryFields(allowedFields(permittedFields), { schema }), traverse.traverseQueryFields(allowedFields(permittedFields), { schema }),
traverse.traverseQueryFields(omitHiddenFields, { schema }),
traverse.traverseQueryFields(removePassword, { schema }) traverse.traverseQueryFields(removePassword, { schema })
); );
@ -256,13 +260,21 @@ module.exports = ({ action, ability, model }) => {
}; };
const getQueryFields = (fields = []) => { const getQueryFields = (fields = []) => {
const nonVisibleAttributes = getNonVisibleAttributes(schema);
const writableAttributes = getWritableAttributes(schema);
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
return uniq([ return uniq([
...fields, ...fields,
...STATIC_FIELDS, ...STATIC_FIELDS,
...COMPONENT_FIELDS, ...COMPONENT_FIELDS,
...nonVisibleWritableAttributes,
CREATED_AT_ATTRIBUTE, CREATED_AT_ATTRIBUTE,
UPDATED_AT_ATTRIBUTE, UPDATED_AT_ATTRIBUTE,
PUBLISHED_AT_ATTRIBUTE, PUBLISHED_AT_ATTRIBUTE,
CREATED_BY_ATTRIBUTE,
UPDATED_BY_ATTRIBUTE,
]); ]);
}; };

View File

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

View File

@ -86,6 +86,10 @@ const isVisible = (schema, name) => {
return false; return false;
} }
if (isCreatorField(schema, name)) {
return false;
}
return true; return true;
}; };
@ -108,6 +112,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 isRelation = (attribute) => attribute.type === 'relation';
const hasRelationAttribute = (schema, name) => { const hasRelationAttribute = (schema, name) => {

View File

@ -52,6 +52,20 @@ const getTimestamps = (model: Model) => {
return attributes; 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) => { const getNonWritableAttributes = (model: Model) => {
if (!model) return []; if (!model) return [];
@ -202,6 +216,7 @@ export {
getNonVisibleAttributes, getNonVisibleAttributes,
getVisibleAttributes, getVisibleAttributes,
getTimestamps, getTimestamps,
getCreatorFields,
isVisibleAttribute, isVisibleAttribute,
hasDraftAndPublish, hasDraftAndPublish,
getOptions, getOptions,