Merge pull request #10857 from strapi/migrations/admin-users-filters

Migrations/admin users filters
This commit is contained in:
cyril lopez 2021-09-02 21:09:17 +02:00 committed by GitHub
commit d4c94aa637
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1800 additions and 662 deletions

View File

@ -54,10 +54,6 @@ describe('<ListPage />', () => {
} = render(App);
expect(firstChild).toMatchInlineSnapshot(`
.c37 {
font-family: Lato;
}
.c5 {
font-weight: 600;
font-size: 2rem;
@ -404,6 +400,10 @@ describe('<ListPage />', () => {
border: none;
}
.c37 {
font-family: Lato;
}
<main
aria-labelledby="title"
class="c0"

View File

@ -0,0 +1,77 @@
/**
* This component is temporary
* FIXME migrate to the common one
*/
import React from 'react';
import { Button } from '@strapi/parts/Button';
import { Dialog, DialogBody, DialogFooter } from '@strapi/parts/Dialog';
import { Row } from '@strapi/parts/Row';
import { Stack } from '@strapi/parts/Stack';
import { Text } from '@strapi/parts/Text';
import { AlertWarningIcon, DeleteIcon } from '@strapi/icons';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
const ConfirmDialog = ({ isConfirmButtonLoading, onConfirm, onToggle, show }) => {
const { formatMessage } = useIntl();
if (!show) {
return null;
}
return (
<Dialog
onClose={onToggle}
title={formatMessage({
id: 'Settings.webhooks.confirmation',
defaultMessage: 'Confirmation',
})}
labelledBy="confirmation"
describedBy="confirm-description"
>
<DialogBody icon={<AlertWarningIcon />}>
<Stack size={2}>
<Row justifyContent="center">
<Text id="confirm-description">
{formatMessage({
id: 'Settings.webhooks.confirmation.delete',
defaultMessage: 'Are you sure you want to delete this?',
})}
</Text>
</Row>
</Stack>
</DialogBody>
<DialogFooter
startAction={
<Button onClick={onToggle} variant="tertiary">
{formatMessage({ id: 'app.components.Button.cancel', defaultMessage: 'Cancel' })}
</Button>
}
endAction={
<Button
onClick={onConfirm}
variant="danger-light"
startIcon={<DeleteIcon />}
id="confirm-delete"
loading={isConfirmButtonLoading}
>
{formatMessage({ id: 'app.components.Button.confirm', defaultMessage: 'Confirm' })}
</Button>
}
/>
</Dialog>
);
};
ConfirmDialog.defaultProps = {
isConfirmButtonLoading: false,
};
ConfirmDialog.propTypes = {
isConfirmButtonLoading: PropTypes.bool,
onConfirm: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
};
export default ConfirmDialog;

View File

@ -3,12 +3,14 @@ import PropTypes from 'prop-types';
import { BaseCheckbox, Box, IconButton, Tbody, Td, Text, Tr, Row } from '@strapi/parts';
import { EditIcon, DeleteIcon } from '@strapi/icons';
import { useHistory } from 'react-router-dom';
import { useIntl } from 'react-intl';
const TableRows = ({
canUpdate,
canDelete,
headers,
entriesToDelete,
onClickDelete,
onSelectRow,
withMainAction,
withBulkActions,
@ -18,6 +20,7 @@ const TableRows = ({
push,
location: { pathname },
} = useHistory();
const { formatMessage } = useIntl();
return (
<Tbody>
@ -55,7 +58,7 @@ const TableRows = ({
{canUpdate && (
<IconButton
onClick={() => push(`${pathname}/${data.id}`)}
label="Edit"
label={formatMessage({ id: 'app.utils.edit', defaultMessage: 'Edit' })}
noBorder
icon={<EditIcon />}
/>
@ -63,8 +66,8 @@ const TableRows = ({
{canDelete && (
<Box paddingLeft={1}>
<IconButton
onClick={() => console.log('delete')}
label="Delete"
onClick={() => onClickDelete(data.id)}
label={formatMessage({ id: 'app.utils.delete', defaultMessage: 'Delete' })}
noBorder
icon={<DeleteIcon />}
/>
@ -83,6 +86,8 @@ const TableRows = ({
TableRows.defaultProps = {
canDelete: false,
canUpdate: false,
onClickDelete: () => {},
onSelectRow: () => {},
rows: [],
withBulkActions: false,
withMainAction: false,
@ -93,7 +98,8 @@ TableRows.propTypes = {
canUpdate: PropTypes.bool,
entriesToDelete: PropTypes.array.isRequired,
headers: PropTypes.array.isRequired,
onSelectRow: PropTypes.func.isRequired,
onClickDelete: PropTypes.func,
onSelectRow: PropTypes.func,
rows: PropTypes.array,
withBulkActions: PropTypes.bool,
withMainAction: PropTypes.bool,

View File

@ -1,13 +1,38 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Table as TableCompo } from '@strapi/parts';
import { Box, Row, Button, Table as TableCompo, Subtitle } from '@strapi/parts';
import { EmptyBodyTable, useQueryParams } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { DeleteIcon } from '@strapi/icons';
import styled from 'styled-components';
import ConfirmDialog from '../ConfirmDialog';
import TableHead from './TableHead';
import TableRows from './TableRows';
const Table = ({ canDelete, canUpdate, headers, rows, withBulkActions, withMainAction }) => {
const BlockActions = styled(Row)`
& > * + * {
margin-left: ${({ theme }) => theme.spaces[2]};
}
margin-left: ${({ pullRight }) => (pullRight ? 'auto' : undefined)};
`;
const Table = ({
canDelete,
canUpdate,
headers,
isLoading,
onConfirmDeleteAll,
rows,
withBulkActions,
withMainAction,
}) => {
const [entriesToDelete, setEntriesToDelete] = useState([]);
const [showConfirmDeleteAll, setShowConfirmDeleteAll] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isConfirmButtonLoading, setIsConfirmButtonLoading] = useState(false);
const [{ query }] = useQueryParams();
const { formatMessage } = useIntl();
const ROW_COUNT = rows.length + 1;
const COL_COUNT = headers.length + (withBulkActions ? 1 : 0) + (withMainAction ? 1 : 0);
const hasFilters = query.filters !== undefined;
@ -21,6 +46,28 @@ const Table = ({ canDelete, canUpdate, headers, rows, withBulkActions, withMainA
}
: undefined;
const handleConfirmDeleteAll = async () => {
try {
setIsConfirmButtonLoading(true);
await onConfirmDeleteAll(entriesToDelete);
handleToggleConfirmDeleteAll();
} catch (err) {
setIsConfirmButtonLoading(false);
handleToggleConfirmDeleteAll();
}
};
const handleConfirmDelete = async () => {
try {
setIsConfirmButtonLoading(true);
await onConfirmDeleteAll(entriesToDelete);
handleToggleConfirmDelete();
} catch (err) {
setIsConfirmButtonLoading(false);
handleToggleConfirmDelete();
}
};
const handleSelectAll = () => {
if (!areAllEntriesSelected) {
setEntriesToDelete(rows.map(row => row.id));
@ -29,6 +76,23 @@ const Table = ({ canDelete, canUpdate, headers, rows, withBulkActions, withMainA
}
};
const handleToggleConfirmDeleteAll = () => {
setShowConfirmDeleteAll(prev => !prev);
};
const handleToggleConfirmDelete = () => {
if (showConfirmDelete) {
setEntriesToDelete([]);
}
setShowConfirmDelete(prev => !prev);
};
const handleClickDelete = id => {
setEntriesToDelete([id]);
handleToggleConfirmDelete();
};
const handleSelectRow = ({ name, value }) => {
setEntriesToDelete(prev => {
if (value) {
@ -40,35 +104,79 @@ const Table = ({ canDelete, canUpdate, headers, rows, withBulkActions, withMainA
};
return (
<TableCompo colCount={COL_COUNT} rowCount={ROW_COUNT}>
<TableHead
areAllEntriesSelected={areAllEntriesSelected}
entriesToDelete={entriesToDelete}
headers={headers}
onSelectAll={handleSelectAll}
withMainAction={withMainAction}
withBulkActions={withBulkActions}
/>
{!rows.length ? (
<EmptyBodyTable colSpan={COL_COUNT} content={content} />
) : (
<TableRows
canDelete={canDelete}
canUpdate={canUpdate}
<>
{entriesToDelete.length > 0 && (
<Box>
<Box paddingBottom={4}>
<Row justifyContent="space-between">
<BlockActions>
<Subtitle textColor="neutral600">
{formatMessage(
{
id: 'content-manager.components.TableDelete.label',
defaultMessage: '{number, plural, one {# entry} other {# entries}} selected',
},
{ number: entriesToDelete.length }
)}
</Subtitle>
<Button
onClick={handleToggleConfirmDeleteAll}
startIcon={<DeleteIcon />}
size="L"
variant="danger-light"
>
{formatMessage({ id: 'app.utils.delete', defaultMessage: 'Delete' })}
</Button>
</BlockActions>
</Row>
</Box>
</Box>
)}
<TableCompo colCount={COL_COUNT} rowCount={ROW_COUNT}>
<TableHead
areAllEntriesSelected={areAllEntriesSelected}
entriesToDelete={entriesToDelete}
headers={headers}
onSelectRow={handleSelectRow}
rows={rows}
withBulkActions={withBulkActions}
onSelectAll={handleSelectAll}
withMainAction={withMainAction}
withBulkActions={withBulkActions}
/>
)}
</TableCompo>
{!rows.length || isLoading ? (
<EmptyBodyTable colSpan={COL_COUNT} content={content} isLoading={isLoading} />
) : (
<TableRows
canDelete={canDelete}
canUpdate={canUpdate}
entriesToDelete={entriesToDelete}
headers={headers}
onClickDelete={handleClickDelete}
onSelectRow={handleSelectRow}
rows={rows}
withBulkActions={withBulkActions}
withMainAction={withMainAction}
/>
)}
</TableCompo>
<ConfirmDialog
isConfirmButtonLoading={isConfirmButtonLoading}
onConfirm={handleConfirmDeleteAll}
onToggle={handleToggleConfirmDeleteAll}
show={showConfirmDeleteAll}
/>
<ConfirmDialog
isConfirmButtonLoading={isConfirmButtonLoading}
onConfirm={handleConfirmDelete}
onToggle={handleToggleConfirmDelete}
show={showConfirmDelete}
/>
</>
);
};
Table.defaultProps = {
headers: [],
isLoading: false,
onConfirmDeleteAll: () => {},
rows: [],
withBulkActions: false,
withMainAction: false,
@ -78,6 +186,8 @@ Table.propTypes = {
canDelete: PropTypes.bool.isRequired,
canUpdate: PropTypes.bool.isRequired,
headers: PropTypes.array,
isLoading: PropTypes.bool,
onConfirmDeleteAll: PropTypes.func,
rows: PropTypes.array,
withBulkActions: PropTypes.bool,
withMainAction: PropTypes.bool,

View File

@ -55,74 +55,6 @@ describe('DynamicTable', () => {
const { container, getByText } = render(app);
expect(container.firstChild).toMatchInlineSnapshot(`
.c24 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #666687;
}
.c19 {
background: #ffffff;
padding: 64px;
}
.c21 {
padding-bottom: 24px;
}
.c23 {
padding-bottom: 16px;
}
.c20 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
text-align: center;
}
.c22 svg {
height: 5.5rem;
}
.c16 tr:last-of-type {
border-bottom: none;
}
.c17 {
border-bottom: 1px solid #eaeaef;
}
.c17 td,
.c17 th {
padding: 16px;
}
.c17 td:first-of-type,
.c17 th:first-of-type {
padding: 0 4px;
}
.c18 {
vertical-align: middle;
text-align: left;
color: #666687;
outline-offset: -4px;
}
.c18 input {
vertical-align: sub;
}
.c12 {
font-weight: 400;
font-size: 0.875rem;
@ -316,6 +248,74 @@ describe('DynamicTable', () => {
height: 0.25rem;
}
.c24 {
font-weight: 500;
font-size: 1rem;
line-height: 1.25;
color: #666687;
}
.c19 {
background: #ffffff;
padding: 64px;
}
.c21 {
padding-bottom: 24px;
}
.c23 {
padding-bottom: 16px;
}
.c20 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
text-align: center;
}
.c22 svg {
height: 5.5rem;
}
.c16 tr:last-of-type {
border-bottom: none;
}
.c17 {
border-bottom: 1px solid #eaeaef;
}
.c17 td,
.c17 th {
padding: 16px;
}
.c17 td:first-of-type,
.c17 th:first-of-type {
padding: 0 4px;
}
.c18 {
vertical-align: middle;
text-align: left;
color: #666687;
outline-offset: -4px;
}
.c18 input {
vertical-align: sub;
}
<div
class="c0"
>

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Box, Tag } from '@strapi/parts';
import { Close } from '@strapi/icons';
import { useQueryParams } from '@strapi/helper-plugin';
const FilterList = () => {
const [{ query }, setQuery] = useQueryParams();
const handleClick = filter => {
const nextFilters = query.filters.$and.filter(f => {
const name = Object.keys(filter)[0];
const filterType = Object.keys(filter[name])[0];
const value = filter[name][filterType];
return f[name]?.[filterType] !== value;
});
setQuery({ filters: { $and: nextFilters }, page: 1 });
};
return (
query.filters?.$and.map((filter, i) => {
const name = Object.keys(filter)[0];
const filterType = Object.keys(filter[name])[0];
const value = filter[name][filterType];
return (
// eslint-disable-next-line react/no-array-index-key
<Box key={`${name}-${i}`} padding={1} onClick={() => handleClick(filter)}>
<Tag icon={<Close />}>
{name} {filterType} {value}
</Tag>
</Box>
);
}) || null
);
};
export default FilterList;

View File

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Select, Field, Stack, FieldInput, Option } from '@strapi/parts';
const Inputs = ({ onChange, type, value }) => {
if (type === 'boolean') {
return (
<Select onChange={onChange} value={value}>
<Option value="true">true</Option>
<Option value="false">false</Option>
</Select>
);
}
// TODO improve
return (
<Field>
<Stack>
<FieldInput onChange={({ target: { value } }) => onChange(value)} value={value} size="S" />
</Stack>
</Field>
);
};
Inputs.defaultProps = {
value: '',
};
Inputs.propTypes = {
onChange: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
value: PropTypes.any,
};
export default Inputs;

View File

@ -0,0 +1,132 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Button, Box, Popover, Stack, Select, Option, FocusTrap } from '@strapi/parts';
import { AddIcon } from '@strapi/icons';
import { useQueryParams } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import Inputs from './Inputs';
import getFilterList from './utils/getFilterList';
const FullWidthButton = styled(Button)`
width: 100%;
`;
const FilterPopover = ({ displayedFilters, isVisible, onToggle, source }) => {
const [{ query }, setQuery] = useQueryParams();
const { formatMessage } = useIntl();
const [modifiedData, setModifiedData] = useState({
name: displayedFilters[0].name,
filter: getFilterList(displayedFilters[0])[0].value,
value: '',
});
if (!isVisible) {
return null;
}
const handleChangeFilterField = value => {
const nextField = displayedFilters.find(f => f.name === value);
const {
fieldSchema: { type },
} = nextField;
setModifiedData({ name: value, filter: '$eq', value: type === 'boolean' ? 'true' : '' });
};
const handleSubmit = e => {
e.preventDefault();
const hasFilter =
query?.filters?.$and.find(filter => {
return (
filter[modifiedData.name] &&
filter[modifiedData.name]?.[modifiedData.filter] === modifiedData.value
);
}) !== undefined;
if (modifiedData.value && !hasFilter) {
const filters = [
...(query?.filters?.$and || []),
{ [modifiedData.name]: { [modifiedData.filter]: modifiedData.value } },
];
setQuery({ filters: { $and: filters }, page: 1 });
}
onToggle();
};
const appliedFilter = displayedFilters.find(filter => filter.name === modifiedData.name);
return (
<Popover source={source} padding={3} spacingTop={1}>
<FocusTrap onEscape={onToggle}>
<form onSubmit={handleSubmit}>
<Stack size={1} style={{ minWidth: 184 }}>
<Box>
<Select
aria-label="Select field"
name="name"
size="S"
onChange={handleChangeFilterField}
value={modifiedData.name}
>
{displayedFilters.map(filter => {
return (
<Option key={filter.name} value={filter.name}>
{filter.metadatas.label}
</Option>
);
})}
</Select>
</Box>
<Box>
<Select
aria-label="Select filter"
name="filter"
size="S"
value={modifiedData.filter}
onChange={val => setModifiedData(prev => ({ ...prev, filter: val }))}
>
{getFilterList(appliedFilter).map(option => {
return (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
);
})}
</Select>
</Box>
<Box>
<Inputs
{...appliedFilter.fieldSchema}
value={modifiedData.value}
onChange={value => setModifiedData(prev => ({ ...prev, value }))}
/>
</Box>
<Box>
<FullWidthButton variant="secondary" startIcon={<AddIcon />} type="submit">
{formatMessage({ id: 'app.utils.add-filter', defaultMessage: 'Add filter' })}
</FullWidthButton>
</Box>
</Stack>
</form>
</FocusTrap>
</Popover>
);
};
FilterPopover.propTypes = {
displayedFilters: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
metadatas: PropTypes.shape({ label: PropTypes.string }),
fieldSchema: PropTypes.shape({ type: PropTypes.string }),
})
).isRequired,
isVisible: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
source: PropTypes.shape({ current: PropTypes.instanceOf(Element) }).isRequired,
};
export default FilterPopover;

View File

@ -0,0 +1,25 @@
/**
* Depending on the selected field find the possible filters to apply
* @param {Object} fieldSchema.type the type of the filter
* @returns {Object[]}
*/
const getFilterList = ({ fieldSchema: { type } }) => {
// TODO needs to be improved for the CM
switch (type) {
case 'email':
case 'string': {
return [
{ label: 'is', value: '$eq' },
{ label: 'is not', value: '$ne' },
{ label: 'contains', value: '$contains' },
];
}
default:
return [
{ label: 'is', value: '$eq' },
{ label: 'is not', value: '$ne' },
];
}
};
export default getFilterList;

View File

@ -0,0 +1,54 @@
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Button, Box } from '@strapi/parts';
import { FilterIcon } from '@strapi/icons';
import FilterList from './FilterList';
import FilterPopover from './FilterPopover';
const Filters = ({ displayedFilters }) => {
const [isVisible, setIsVisible] = useState(false);
const { formatMessage } = useIntl();
const buttonRef = useRef();
const handleToggle = () => {
setIsVisible(prev => !prev);
};
return (
<>
<Box padding={1}>
<Button
variant="tertiary"
ref={buttonRef}
startIcon={<FilterIcon />}
onClick={handleToggle}
size="S"
>
{formatMessage({ id: 'app.utils.filters', defaultMessage: 'Filters' })}
</Button>
{isVisible && (
<FilterPopover
displayedFilters={displayedFilters}
isVisible={isVisible}
onToggle={handleToggle}
source={buttonRef}
/>
)}
</Box>
<FilterList />
</>
);
};
Filters.propTypes = {
displayedFilters: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
metadatas: PropTypes.shape({ label: PropTypes.string }),
fieldSchema: PropTypes.shape({ type: PropTypes.string }),
})
).isRequired,
};
export default Filters;

View File

@ -23,7 +23,7 @@ const PageSize = () => {
return (
<Row>
<Select onChange={handleChange} value={pageSize}>
<Select aria-label="Entries per page" onChange={handleChange} value={pageSize}>
<Option value="10">10</Option>
<Option value="20">20</Option>
<Option value="50">50</Option>

View File

@ -60,7 +60,13 @@ const Pagination = ({ pagination: { pageCount } }) => {
}
if (activePage === 2 && pageCount >= 3) {
firstLinksToCreate = pageCount === 5 ? [2, 3, 4] : [2, 3];
if (pageCount === 5) {
firstLinksToCreate = [2, 3, 4];
} else if (pageCount === 3) {
firstLinksToCreate = [2];
} else {
firstLinksToCreate = [2, 3];
}
}
if (activePage === 4 && pageCount >= 3) {
@ -71,15 +77,15 @@ const Pagination = ({ pagination: { pageCount } }) => {
lastLinksToCreate = [pageCount - 1];
}
if (activePage === pageCount - 2 && pageCount >= 3) {
if (activePage === pageCount - 2 && pageCount > 3) {
lastLinksToCreate = [activePage + 1, activePage, activePage - 1];
}
if (activePage === pageCount - 3 && pageCount >= 3 && activePage > 5) {
if (activePage === pageCount - 3 && pageCount > 3 && activePage > 5) {
lastLinksToCreate = [activePage + 2, activePage + 1, activePage, activePage - 1];
}
if (activePage === pageCount - 1 && pageCount >= 3) {
if (activePage === pageCount - 1 && pageCount > 3) {
lastLinksToCreate = [activePage, activePage - 1];
}
@ -99,7 +105,11 @@ const Pagination = ({ pagination: { pageCount } }) => {
);
});
if (![1, 2].includes(activePage) && activePage < pageCount - 3) {
if (
![1, 2].includes(activePage) &&
activePage <= pageCount - 3 &&
firstLinks.length + lastLinks.length < 6
) {
const middleLinksToCreate = [activePage - 1, activePage, activePage + 1];
middleLinksToCreate.forEach(number => {

View File

@ -283,6 +283,7 @@ exports[`DynamicTable renders and matches the snapshot 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Entries per page"
aria-labelledby="select-1-label select-1-content"
class="c6"
id="select-1"

View File

@ -0,0 +1,44 @@
import React, { useEffect, useState } from 'react';
import { useQueryParams } from '@strapi/helper-plugin';
import { SearchIcon } from '@strapi/icons';
import { IconButton } from '@strapi/parts/IconButton';
import { TextInput } from '@strapi/parts/TextInput';
const Search = () => {
const [isOpen, setIsOpen] = useState(false);
const [{ query }, setQuery] = useQueryParams();
const [value, setValue] = useState(query._q || '');
useEffect(() => {
const handler = setTimeout(() => {
if (value) {
setQuery({ _q: value, page: 1 });
} else {
setQuery({ _q: '' }, 'remove');
}
}, 300);
return () => clearTimeout(handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const handleToggle = () => {
setIsOpen(prev => !prev);
};
if (isOpen) {
return (
<TextInput
onBlur={() => setIsOpen(false)}
name="search"
onChange={({ target: { value } }) => setValue(value)}
type="text"
value={value}
/>
);
}
return <IconButton icon={<SearchIcon />} label="Search" onClick={handleToggle} />;
};
export default Search;

View File

@ -1,42 +1,36 @@
import React from 'react';
import {
CustomContentLayout,
LoadingIndicatorPage,
useRBAC,
SettingsPageTitle,
useNotification,
useFocusWhenNavigate,
} from '@strapi/helper-plugin';
import { Button, HeaderLayout, Main } from '@strapi/parts';
import { Button, Box, HeaderLayout, Main, Row } from '@strapi/parts';
import { Mail } from '@strapi/icons';
import {
// useHistory,
useLocation,
} from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { useQuery } from 'react-query';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import get from 'lodash/get';
import adminPermissions from '../../../permissions';
import DynamicTable from './DynamicTable';
import Filters from './Filters';
import Search from './Search';
import PaginationFooter from './PaginationFooter';
import fetchData from './utils/api';
import { deleteData, fetchData } from './utils/api';
import displayedFilters from './utils/displayedFilters';
import tableHeaders from './utils/tableHeaders';
const ListPage = () => {
const {
allowedActions: { canCreate, canDelete, canRead, canUpdate },
} = useRBAC(adminPermissions.settings.users);
const queryClient = useQueryClient();
const toggleNotification = useNotification();
// const [isWarningDeleteAllOpened, setIsWarningDeleteAllOpened] = useState(false);
// const [isModalOpened, setIsModalOpened] = useState(false);
const { formatMessage } = useIntl();
// const query = useQuery();
// const { push } = useHistory();
const { search } = useLocation();
const { status, data, isFetching } = useQuery(['projects', search], () => fetchData(search), {
const { status, data, isFetching } = useQuery(['users', search], () => fetchData(search), {
enabled: canRead,
keepPreviousData: true,
retry: false,
@ -53,181 +47,21 @@ const ListPage = () => {
const total = get(data, 'pagination.total', 0);
// const filters = useMemo(() => {
// return getFilters(search);
// }, [search]);
// const [
// {
// // data,
// // dataToDelete,
// // isLoading,
// pagination: { total },
// // shouldRefetchData,
// // showModalConfirmButtonLoading,
// },
// dispatch,
// ] = useReducer(reducer, initialState, init);
// const pageSize = parseInt(query.get('pageSize') || 10, 10);
// const page = parseInt(query.get('page') || 0, 10);
// const sort = decodeURIComponent(query.get('sort'));
// const _q = decodeURIComponent(query.get('_q') || '');
// const getDataRef = useRef();
// const listRef = useRef();
// getDataRef.current = async () => {
// if (!canRead) {
// dispatch({
// type: 'UNSET_IS_LOADING',
// });
// return;
// }
// // Show the loading state and reset the state
// dispatch({
// type: 'GET_DATA',
// });
// try {
// const {
// data: { results, pagination },
// } = await request(`/admin/users${search}`, { method: 'GET' });
// dispatch({
// type: 'GET_DATA_SUCCEEDED',
// data: results,
// pagination,
// });
// } catch (err) {
// console.error(err.response);
// toggleNotification({
// type: 'warning',
// message: { id: 'notification.error' },
// });
// }
// };
// useEffect(() => {
// if (!isLoadingForPermissions) {
// getDataRef.current();
// }
// }, [search, isLoadingForPermissions]);
// const handleChangeDataToDelete = ids => {
// dispatch({
// type: 'ON_CHANGE_DATA_TO_DELETE',
// dataToDelete: ids,
// });
// };
// const handleChangeFilter = ({ filter, name, value }) => {
// const filterName = `${name}${filter}`;
// updateSearchParams(filterName, encodeURIComponent(value), true);
// };
// const handleChangeFooterParams = ({ target: { name, value } }) => {
// let paramName = name.split('.')[1].replace('_', '');
// if (paramName === 'limit') {
// paramName = 'pageSize';
// }
// updateSearchParams(paramName, value);
// };
// const handleChangeSort = ({ target: { name, value } }) => {
// updateSearchParams(name, value);
// };
// const handleClickDeleteFilter = ({ target: { name } }) => {
// const currentSearch = new URLSearchParams(search);
// currentSearch.delete(name);
// push({ search: currentSearch.toString() });
// };
// const handleClickDelete = useCallback(id => {
// handleToggleModal();
// dispatch({
// type: 'ON_CHANGE_DATA_TO_DELETE',
// dataToDelete: [id],
// });
// }, []);
// const handleCloseModal = () => {
// // Refetch data
// getDataRef.current();
// };
// const handleClosedModalDelete = () => {
// if (shouldRefetchData) {
// getDataRef.current();
// } else {
// // Empty the selected ids when the modal closes
// dispatch({
// type: 'RESET_DATA_TO_DELETE',
// });
// // Reset the list's reducer dataToDelete state using a ref so we don't need an effect
// listRef.current.resetDataToDelete();
// }
// };
// const handleConfirmDeleteData = useCallback(async () => {
// dispatch({
// type: 'ON_DELETE_USERS',
// });
// let shouldDispatchSucceededAction = false;
// try {
// await request('/admin/users/batch-delete', {
// method: 'POST',
// body: {
// ids: dataToDelete,
// },
// });
// shouldDispatchSucceededAction = true;
// } catch (err) {
// const errorMessage = get(err, 'response.payload.data', 'An error occured');
// toggleNotification({
// type: 'warning',
// message: errorMessage,
// });
// }
// // Only dispatch the action once
// if (shouldDispatchSucceededAction) {
// dispatch({
// type: 'ON_DELETE_USERS_SUCCEEDED',
// });
// }
// handleToggleModal();
// }, [dataToDelete, toggleNotification]);
// const handleToggle = () => setIsModalOpened(prev => !prev);
// const handleToggleModal = () => setIsWarningDeleteAllOpened(prev => !prev);
// const updateSearchParams = (name, value, shouldDeleteSearch = false) => {
// const currentSearch = new URLSearchParams(search);
// // Update the currentSearch
// currentSearch.set(name, value);
// if (shouldDeleteSearch) {
// currentSearch.delete('_q');
// }
// push({
// search: currentSearch.toString(),
// });
// };
const deleteAllMutation = useMutation(ids => deleteData(ids), {
onSuccess: async () => {
await queryClient.invalidateQueries(['users', search]);
},
onError: err => {
if (err?.response?.data?.data) {
toggleNotification({ type: 'warning', message: err.response.data.data });
} else {
toggleNotification({
type: 'warning',
message: { id: 'notification.error', defaultMessage: 'An error occured' },
});
}
},
});
// This can be improved but we need to show an something to the user
const isLoading =
@ -266,16 +100,26 @@ const ListPage = () => {
{ number: total }
)}
/>
<CustomContentLayout action={createAction} canRead={canRead}>
<CustomContentLayout canRead={canRead}>
{status === 'error' && <div>TODO: An error occurred</div>}
{canRead && isLoading ? (
<LoadingIndicatorPage />
) : (
{canRead && (
<>
<Box paddingBottom={4}>
<Row style={{ flexWrap: 'wrap' }}>
<Search />
<Filters displayedFilters={displayedFilters} />
</Row>
</Box>
</>
)}
{canRead && (
<>
<DynamicTable
canCreate={canCreate}
canDelete={canDelete}
canUpdate={canUpdate}
isLoading={isLoading}
onConfirmDeleteAll={deleteAllMutation.mutateAsync}
headers={tableHeaders}
rows={data?.results}
withBulkActions

View File

@ -1,57 +0,0 @@
/* eslint-disable consistent-return */
import produce from 'immer';
const initialState = {
data: [],
dataToDelete: [],
isLoading: true,
pagination: {
page: 1,
pageSize: 10,
pageCount: 0,
total: 0,
},
showModalConfirmButtonLoading: false,
shouldRefetchData: false,
};
const reducer = (state, action) =>
produce(state, draftState => {
switch (action.type) {
case 'GET_DATA': {
return initialState;
}
case 'GET_DATA_SUCCEEDED': {
draftState.data = action.data;
draftState.isLoading = false;
draftState.pagination = action.pagination;
break;
}
case 'ON_CHANGE_DATA_TO_DELETE': {
draftState.dataToDelete = action.dataToDelete;
break;
}
case 'ON_DELETE_USERS': {
draftState.showModalConfirmButtonLoading = true;
break;
}
case 'ON_DELETE_USERS_SUCCEEDED': {
draftState.shouldRefetchData = true;
break;
}
case 'RESET_DATA_TO_DELETE': {
draftState.shouldRefetchData = false;
draftState.dataToDelete = [];
draftState.showModalConfirmButtonLoading = false;
break;
}
case 'UNSET_IS_LOADING': {
draftState.isLoading = false;
break;
}
default:
return draftState;
}
});
export { initialState, reducer };

File diff suppressed because one or more lines are too long

View File

@ -1,182 +0,0 @@
import { reducer } from '../reducer';
describe('ADMIN | CONTAINERS | USERS | ListPage | reducer', () => {
describe('DEFAULT_ACTION', () => {
it('should return the initialState', () => {
const initialState = {
test: true,
};
expect(reducer(initialState, {})).toEqual(initialState);
});
});
describe('GET_DATA', () => {
it('should return the initialState', () => {
const initialState = {
test: true,
};
const action = {
type: 'GET_DATA',
};
const expected = {
data: [],
dataToDelete: [],
isLoading: true,
pagination: {
page: 1,
pageSize: 10,
pageCount: 0,
total: 0,
},
showModalConfirmButtonLoading: false,
shouldRefetchData: false,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('GET_DATA_SUCCEEDED', () => {
it('Should set the data correctly', () => {
const action = {
type: 'GET_DATA_SUCCEEDED',
data: [1, 2, 3],
pagination: {
page: 1,
pageSize: 10,
pageCount: 5,
total: 20,
},
};
const initialState = {
data: [],
dataToDelete: [],
isLoading: true,
pagination: {},
};
const expected = {
data: [1, 2, 3],
dataToDelete: [],
isLoading: false,
pagination: {
page: 1,
pageSize: 10,
pageCount: 5,
total: 20,
},
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('ON_CHANGE_DATA_TO_DELETE', () => {
it('should change the data correctly', () => {
const initialState = {
data: [],
dataToDelete: [],
isLoading: true,
};
const action = {
type: 'ON_CHANGE_DATA_TO_DELETE',
dataToDelete: [1, 2],
};
const expected = {
data: [],
dataToDelete: [1, 2],
isLoading: true,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('ON_DELETE_USERS', () => {
it('should set the showModalConfirmButtonLoading to true', () => {
const initialState = {
data: [1],
dataToDelete: [1],
showModalConfirmButtonLoading: false,
};
const action = {
type: 'ON_DELETE_USERS',
};
const expected = {
data: [1],
dataToDelete: [1],
showModalConfirmButtonLoading: true,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('ON_DELETE_USERS_SUCCEEDED', () => {
it('should set the shouldRefetchData to true', () => {
const initialState = {
data: [1],
dataToDelete: [1],
showModalConfirmButtonLoading: true,
shouldRefetchData: false,
};
const action = {
type: 'ON_DELETE_USERS_SUCCEEDED',
};
const expected = {
data: [1],
dataToDelete: [1],
showModalConfirmButtonLoading: true,
shouldRefetchData: true,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('RESET_DATA_TO_DELETE', () => {
it('should empty the dataToDelete array', () => {
const initialState = {
data: [1],
dataToDelete: [1],
showModalConfirmButtonLoading: true,
shouldRefetchData: true,
};
const action = {
type: 'RESET_DATA_TO_DELETE',
};
const expected = {
data: [1],
dataToDelete: [],
showModalConfirmButtonLoading: false,
shouldRefetchData: false,
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
describe('UNSET_IS_LOADING', () => {
it('should set the isLoading to false', () => {
const action = {
type: 'UNSET_IS_LOADING',
};
const initialState = {
data: [],
dataToDelete: [],
isLoading: true,
pagination: {},
};
const expected = {
data: [],
dataToDelete: [],
isLoading: false,
pagination: {},
};
expect(reducer(initialState, action)).toEqual(expected);
});
});
});

View File

@ -1,15 +1,15 @@
import { axiosInstance } from '../../../../core/utils';
const fetchData = async search => {
try {
const {
data: { data },
} = await axiosInstance.get(`/admin/users${search}`);
const {
data: { data },
} = await axiosInstance.get(`/admin/users${search}`);
return data;
} catch (err) {
throw new Error(err);
}
return data;
};
export default fetchData;
const deleteData = async ids => {
await axiosInstance.post('/admin/users/batch-delete', { ids });
};
export { deleteData, fetchData };

View File

@ -0,0 +1,29 @@
const displayedFilters = [
{
name: 'firstname',
metadatas: { label: 'Firstname' },
fieldSchema: { type: 'string' },
},
{
name: 'lastname',
metadatas: { label: 'Lastname' },
fieldSchema: { type: 'string' },
},
{
name: 'email',
metadatas: { label: 'Email' },
fieldSchema: { type: 'email' },
},
{
name: 'username',
metadatas: { label: 'Username' },
fieldSchema: { type: 'string' },
},
{
name: 'isActive',
metadatas: { label: 'Active user' },
fieldSchema: { type: 'boolean' },
},
];
export default displayedFilters;

View File

@ -1,30 +0,0 @@
const getFilters = search => {
const query = new URLSearchParams(search);
const filters = [];
// eslint-disable-next-line no-restricted-syntax
for (let [key, queryValue] of query.entries()) {
if (!['sort', 'pageSize', 'page', '_q'].includes(key)) {
const splitted = key.split('_');
let filterName;
let filterType;
// Filter type === '=')
if (splitted.length === 1) {
filterType = '=';
filterName = key;
} else {
filterType = `_${splitted[1]}`;
filterName = splitted[0];
}
const value = decodeURIComponent(queryValue);
filters.push({ displayName: filterName, name: key, filter: filterType, value });
}
}
return filters;
};
export default getFilters;

View File

@ -1,29 +0,0 @@
import getFilters from '../getFilters';
describe('ADMIN | CONTAINERS | USERS | ListPage | utils | getFilters', () => {
it('should return an empty array if there is not filter', () => {
const search = '_q=test&sort=firstname&page=1&pageSize=1';
expect(getFilters(search)).toHaveLength(0);
});
it('should handle the = filter correctly ', () => {
const search = 'sort=firstname&page=1&pageSize=1&firstname=test&firstname_ne=something';
const expected = [
{
displayName: 'firstname',
name: 'firstname',
filter: '=',
value: 'test',
},
{
displayName: 'firstname',
name: 'firstname_ne',
filter: '_ne',
value: 'something',
},
];
expect(getFilters(search)).toEqual(expected);
});
});

View File

@ -420,8 +420,7 @@
"content-manager.components.SettingsViewWrapper.pluginHeader.title": "Configure the view - {name}",
"content-manager.components.TableDelete.delete": "Delete all",
"content-manager.components.TableDelete.deleteSelected": "Delete selected",
"content-manager.components.TableDelete.entries.plural": "{number} entries selected",
"content-manager.components.TableDelete.entries.singular": "{number} entry selected",
"content-manager.components.TableDelete.label": "{number, plural, one {# entry} other {# entries}} selected",
"content-manager.components.TableEmpty.withFilters": "There are no {contentType} with the applied filters...",
"content-manager.components.TableEmpty.withSearch": "There are no {contentType} corresponding to the search ({search})...",
"content-manager.components.TableEmpty.withoutFilter": "There are no {contentType}...",

View File

@ -19,7 +19,7 @@ import EmptyBodyTable from './index';
This component is used to display an empty state in a table.
## Usage
## Usage default
<Canvas>
<Story name="base">
@ -31,6 +31,18 @@ This component is used to display an empty state in a table.
</Story>
</Canvas>
## Usage loading
<Canvas>
<Story name="loading">
<Box>
<Table colCount={1} rowCount={2} footer={undefined}>
<EmptyBodyTable isLoading />
</Table>
</Box>
</Story>
</Canvas>
## Props
<ArgsTable of={EmptyBodyTable} />

View File

@ -1,10 +1,25 @@
import React from 'react';
import { Tbody, Tr, Td } from '@strapi/parts/Table';
import styled from 'styled-components';
import { Box, Row, Loader } from '@strapi/parts';
import EmptyStateLayout from '../EmptyStateLayout';
import PropTypes from 'prop-types';
const EmptyBodyTable = ({ colSpan, ...rest }) => {
const EmptyBodyTable = ({ colSpan, isLoading, ...rest }) => {
if (isLoading) {
return (
<Tbody>
<Tr>
<Td colSpan={colSpan}>
<Row justifyContent="center">
<Box padding={11} background="neutral0">
<Loader />
</Box>
</Row>
</Td>
</Tr>
</Tbody>
);
}
return (
<Tbody>
<Tr>
@ -20,8 +35,8 @@ EmptyBodyTable.defaultProps = {
action: undefined,
colSpan: 1,
content: undefined,
icon: undefined,
isLoading: false,
};
EmptyBodyTable.propTypes = {
@ -33,6 +48,7 @@ EmptyBodyTable.propTypes = {
values: PropTypes.object,
}),
icon: PropTypes.oneOf(['document', 'media', 'permissions']),
isLoading: PropTypes.bool,
};
export default EmptyBodyTable;