Merge pull request #19020 from strapi/chore/convert-audit-logs-ee-pages

chore(admin): convert auditLogs page to TS
This commit is contained in:
ELABBASSI Hicham 2023-12-14 09:56:47 +01:00 committed by GitHub
commit 4e6961c7b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 560 additions and 466 deletions

View File

@ -5,10 +5,9 @@ export const ROUTES_EE: Route[] = [
? [
{
async Component() {
// @ts-expect-error No types, yet.
const component = await import('./pages/AuditLogs/ProtectedListPage');
const { ProtectedAuditLogsListPage } = await import('./pages/AuditLogs/ListPage');
return component;
return ProtectedAuditLogsListPage;
},
to: '/settings/audit-logs',
exact: true,

View File

@ -0,0 +1,148 @@
import {
ActionLayout,
Box,
ContentLayout,
HeaderLayout,
Layout,
Main,
} from '@strapi/design-system';
import {
AnErrorOccurred,
DynamicTable,
SettingsPageTitle,
useFocusWhenNavigate,
useQueryParams,
useRBAC,
CheckPagePermissions,
} from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { useSelector } from 'react-redux';
import { Filters } from '../../../../../../../admin/src/pages/Settings/components/Filters';
import { selectAdminPermissions } from '../../../../../../../admin/src/selectors';
import { SanitizedAdminUserForAuditLogs } from '../../../../../../../shared/contracts/audit-logs';
import { Modal } from './components/Modal';
import { PaginationFooter } from './components/PaginationFooter';
import { TableHeader, TableRows } from './components/TableRows';
import { useAuditLogsData } from './hooks/useAuditLogsData';
import { getDisplayedFilters } from './utils/getDisplayedFilters';
export const ListView = () => {
const { formatMessage } = useIntl();
const permissions = useSelector(selectAdminPermissions);
const {
allowedActions: { canRead: canReadAuditLogs, canReadUsers },
} = useRBAC({
...permissions.settings?.auditLogs,
readUsers: permissions.settings?.users.read || [],
});
const [{ query }, setQuery] = useQueryParams<{ id?: string | null }>();
const { auditLogs, users, isLoading, hasError } = useAuditLogsData({
canReadAuditLogs,
canReadUsers,
});
useFocusWhenNavigate();
const displayedFilters = getDisplayedFilters({ formatMessage, users, canReadUsers });
const title = formatMessage({
id: 'global.auditLogs',
defaultMessage: 'Audit Logs',
});
const headers = [
{
name: 'action',
key: 'action',
metadatas: {
label: formatMessage({
id: 'Settings.permissions.auditLogs.action',
defaultMessage: 'Action',
}),
sortable: true,
},
},
{
name: 'date',
key: 'date',
metadatas: {
label: formatMessage({
id: 'Settings.permissions.auditLogs.date',
defaultMessage: 'Date',
}),
sortable: true,
},
},
{
key: 'user',
name: 'user',
metadatas: {
label: formatMessage({
id: 'Settings.permissions.auditLogs.user',
defaultMessage: 'User',
}),
sortable: false,
},
// In this case, the passed parameter cannot and shouldn't be something else than User
cellFormatter: (user) => (user ? (user as SanitizedAdminUserForAuditLogs).displayName : ''),
},
] satisfies TableHeader[];
if (hasError) {
return (
<Layout>
<ContentLayout>
<Box paddingTop={8}>
<AnErrorOccurred />
</Box>
</ContentLayout>
</Layout>
);
}
return (
<Main aria-busy={isLoading}>
<SettingsPageTitle name={title} />
<HeaderLayout
title={title}
subtitle={formatMessage({
id: 'Settings.permissions.auditLogs.listview.header.subtitle',
defaultMessage: 'Logs of all the activities that happened in your environment',
})}
/>
{/* @ts-expect-error TODO: fix the way filters work and are passed around, this will be a headache. */}
<ActionLayout startActions={<Filters displayedFilters={displayedFilters} />} />
<ContentLayout>
<DynamicTable
contentType="Audit logs"
headers={headers}
rows={auditLogs?.results || []}
withBulkActions
isLoading={isLoading}
>
<TableRows
headers={headers}
rows={auditLogs?.results || []}
onOpenModal={(id) => setQuery({ id: `${id}` })}
/>
</DynamicTable>
{auditLogs?.pagination && <PaginationFooter pagination={auditLogs.pagination} />}
</ContentLayout>
{query?.id && <Modal handleClose={() => setQuery({ id: null }, 'remove')} logId={query.id} />}
</Main>
);
};
export const ProtectedAuditLogsListPage = () => {
const permissions = useSelector(selectAdminPermissions);
return (
<CheckPagePermissions permissions={permissions.settings?.auditLogs?.main}>
<ListView />
</CheckPagePermissions>
);
};

View File

@ -1,42 +0,0 @@
import React from 'react';
import { Combobox, ComboboxOption } from '@strapi/design-system';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
const ComboboxFilter = ({ value, options, onChange }) => {
const { formatMessage } = useIntl();
const ariaLabel = formatMessage({
id: 'Settings.permissions.auditLogs.filter.aria-label',
defaultMessage: 'Search and select an option to filter',
});
return (
<Combobox aria-label={ariaLabel} value={value} onChange={onChange}>
{options.map(({ label, customValue }) => {
return (
<ComboboxOption key={customValue} value={customValue}>
{label}
</ComboboxOption>
);
})}
</Combobox>
);
};
ComboboxFilter.defaultProps = {
value: null,
};
ComboboxFilter.propTypes = {
value: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
customValue: PropTypes.string.isRequired,
}).isRequired
).isRequired,
onChange: PropTypes.func.isRequired,
};
export default ComboboxFilter;

View File

@ -1,107 +0,0 @@
import React from 'react';
import { Box, Flex, Grid, JSONInput, Loader, Typography } from '@strapi/design-system';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
import ActionItem from './ActionItem';
const ActionBody = ({ status, data, formattedDate }) => {
const { formatMessage } = useIntl();
if (status === 'loading') {
return (
<Flex padding={7} justifyContent="center" alignItems="center">
<Loader>Loading content...</Loader>
</Flex>
);
}
const { action, user, payload } = data;
return (
<>
<Box marginBottom={3}>
<Typography variant="delta" id="title">
{formatMessage({
id: 'Settings.permissions.auditLogs.details',
defaultMessage: 'Log Details',
})}
</Typography>
</Box>
<Grid
gap={4}
gridCols={2}
paddingTop={4}
paddingBottom={4}
paddingLeft={6}
paddingRight={6}
marginBottom={4}
background="neutral100"
hasRadius
>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.action',
defaultMessage: 'Action',
})}
actionName={formatMessage(
{
id: `Settings.permissions.auditLogs.${action}`,
defaultMessage: getDefaultMessage(action),
},
{ model: payload?.model }
)}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.date',
defaultMessage: 'Date',
})}
actionName={formattedDate}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.user',
defaultMessage: 'User',
})}
actionName={user?.displayName || '-'}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.userId',
defaultMessage: 'User ID',
})}
actionName={user?.id.toString() || '-'}
/>
</Grid>
<JSONInput
value={JSON.stringify(payload, null, 2)}
disabled
label={formatMessage({
id: 'Settings.permissions.auditLogs.payload',
defaultMessage: 'Payload',
})}
/>
</>
);
};
ActionBody.defaultProps = {
data: {},
};
ActionBody.propTypes = {
status: PropTypes.oneOf(['idle', 'loading', 'error', 'success']).isRequired,
data: PropTypes.shape({
action: PropTypes.string,
date: PropTypes.string,
payload: PropTypes.object,
user: PropTypes.object,
}),
formattedDate: PropTypes.string.isRequired,
};
export default ActionBody;

View File

@ -1,22 +0,0 @@
import React from 'react';
import { Flex, Typography } from '@strapi/design-system';
import PropTypes from 'prop-types';
const ActionItem = ({ actionLabel, actionName }) => {
return (
<Flex direction="column" alignItems="baseline" gap={1}>
<Typography textColor="neutral600" variant="sigma">
{actionLabel}
</Typography>
<Typography textColor="neutral600">{actionName}</Typography>
</Flex>
);
};
ActionItem.propTypes = {
actionLabel: PropTypes.string.isRequired,
actionName: PropTypes.string.isRequired,
};
export default ActionItem;

View File

@ -1,62 +0,0 @@
import React from 'react';
import { ModalBody, ModalHeader, ModalLayout } from '@strapi/design-system';
import { Breadcrumbs, Crumb } from '@strapi/design-system/v2';
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useQuery } from 'react-query';
import useFormatTimeStamp from '../hooks/useFormatTimeStamp';
import ActionBody from './ActionBody';
const Modal = ({ handleClose, logId }) => {
const { get } = useFetchClient();
const toggleNotification = useNotification();
const fetchAuditLog = async (id) => {
const { data } = await get(`/admin/audit-logs/${id}`);
if (!data) {
throw new Error('Audit log not found');
}
return data;
};
const { data, status } = useQuery(['audit-log', logId], () => fetchAuditLog(logId), {
onError() {
toggleNotification({
type: 'warning',
message: { id: 'notification.error', defaultMessage: 'An error occured' },
});
handleClose();
},
});
const formatTimeStamp = useFormatTimeStamp();
const formattedDate = data ? formatTimeStamp(data.date) : '';
return (
<ModalLayout onClose={handleClose} labelledBy="title">
<ModalHeader>
{/**
* TODO: this is not semantically correct and should be amended.
*/}
<Breadcrumbs label={formattedDate} id="title">
<Crumb isCurrent>{formattedDate}</Crumb>
</Breadcrumbs>
</ModalHeader>
<ModalBody>
<ActionBody status={status} data={data} formattedDate={formattedDate} />
</ModalBody>
</ModalLayout>
);
};
Modal.propTypes = {
handleClose: PropTypes.func.isRequired,
logId: PropTypes.string.isRequired,
};
export default Modal;

View File

@ -1,35 +0,0 @@
import React from 'react';
import { Box, Flex } from '@strapi/design-system';
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
const PaginationFooter = ({ pagination }) => {
return (
<Box paddingTop={4}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
);
};
PaginationFooter.defaultProps = {
pagination: {
pageCount: 0,
pageSize: 50,
total: 0,
},
};
PaginationFooter.propTypes = {
pagination: PropTypes.shape({
page: PropTypes.number,
pageCount: PropTypes.number,
pageSize: PropTypes.number,
total: PropTypes.number,
}),
};
export default PaginationFooter;

View File

@ -1,110 +0,0 @@
import React from 'react';
import {
ActionLayout,
Box,
ContentLayout,
HeaderLayout,
Layout,
Main,
} from '@strapi/design-system';
import {
AnErrorOccurred,
DynamicTable,
SettingsPageTitle,
useFocusWhenNavigate,
useQueryParams,
useRBAC,
} from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { useSelector } from 'react-redux';
import { Filters } from '../../../../../../../../admin/src/pages/Settings/components/Filters';
import { selectAdminPermissions } from '../../../../../../../../admin/src/selectors';
import useAuditLogsData from './hooks/useAuditLogsData';
import Modal from './Modal';
import PaginationFooter from './PaginationFooter';
import TableRows from './TableRows';
import getDisplayedFilters from './utils/getDisplayedFilters';
import tableHeaders from './utils/tableHeaders';
const ListView = () => {
const { formatMessage } = useIntl();
const permissions = useSelector(selectAdminPermissions);
const {
allowedActions: { canRead: canReadAuditLogs, canReadUsers },
} = useRBAC({
...permissions.settings.auditLogs,
readUsers: permissions.settings.users.read,
});
const [{ query }, setQuery] = useQueryParams();
const { auditLogs, users, isLoading, hasError } = useAuditLogsData({
canReadAuditLogs,
canReadUsers,
});
useFocusWhenNavigate();
const displayedFilters = getDisplayedFilters({ formatMessage, users, canReadUsers });
const title = formatMessage({
id: 'global.auditLogs',
defaultMessage: 'Audit Logs',
});
const headers = tableHeaders.map((header) => ({
...header,
metadatas: {
...header.metadatas,
label: formatMessage(header.metadatas.label),
},
}));
if (hasError) {
return (
<Layout>
<ContentLayout>
<Box paddingTop={8}>
<AnErrorOccurred />
</Box>
</ContentLayout>
</Layout>
);
}
return (
<Main aria-busy={isLoading}>
<SettingsPageTitle name={title} />
<HeaderLayout
title={title}
subtitle={formatMessage({
id: 'Settings.permissions.auditLogs.listview.header.subtitle',
defaultMessage: 'Logs of all the activities that happened in your environment',
})}
/>
<ActionLayout startActions={<Filters displayedFilters={displayedFilters} />} />
<ContentLayout canRead={canReadAuditLogs}>
<DynamicTable
contentType="Audit logs"
headers={headers}
rows={auditLogs?.results || []}
withBulkActions
isLoading={isLoading}
>
<TableRows
headers={headers}
rows={auditLogs?.results || []}
onOpenModal={(id) => setQuery({ id })}
/>
</DynamicTable>
<PaginationFooter pagination={auditLogs?.pagination} />
</ContentLayout>
{query?.id && <Modal handleClose={() => setQuery({ id: null }, 'remove')} logId={query.id} />}
</Main>
);
};
export default ListView;

View File

@ -1,19 +0,0 @@
import React from 'react';
import { CheckPagePermissions } from '@strapi/helper-plugin';
import { useSelector } from 'react-redux';
import { selectAdminPermissions } from '../../../../../../../../admin/src/selectors';
import ListView from '../ListView';
const ProtectedListPage = () => {
const permissions = useSelector(selectAdminPermissions);
return (
<CheckPagePermissions permissions={permissions.settings.auditLogs.main}>
<ListView />
</CheckPagePermissions>
);
};
export default ProtectedListPage;

View File

@ -0,0 +1,32 @@
import { Combobox, ComboboxOption, ComboboxProps } from '@strapi/design-system';
import { useIntl } from 'react-intl';
type ComboboxFilterProps = {
value?: string;
options?: { label: string; customValue: string }[];
onChange?: ComboboxProps['onChange'];
};
export const ComboboxFilter = (
{ value, options, onChange }: ComboboxFilterProps = {
value: undefined,
}
) => {
const { formatMessage } = useIntl();
const ariaLabel = formatMessage({
id: 'Settings.permissions.auditLogs.filter.aria-label',
defaultMessage: 'Search and select an option to filter',
});
return (
<Combobox aria-label={ariaLabel} value={value} onChange={onChange}>
{options?.map(({ label, customValue }) => {
return (
<ComboboxOption key={customValue} value={customValue}>
{label}
</ComboboxOption>
);
})}
</Combobox>
);
};

View File

@ -0,0 +1,175 @@
import {
Box,
Flex,
Grid,
JSONInput,
Loader,
ModalBody,
ModalHeader,
ModalLayout,
Typography,
} from '@strapi/design-system';
import { Breadcrumbs, Crumb } from '@strapi/design-system/v2';
import { useFetchClient, useNotification } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { QueryStatus, useQuery } from 'react-query';
import { AuditLog, Get } from '../../../../../../../../shared/contracts/audit-logs';
import { useFormatTimeStamp } from '../hooks/useFormatTimeStamp';
import { actionTypes, getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
type ActionBodyProps = {
status: QueryStatus;
data: AuditLog;
formattedDate: string;
};
type ModalProps = {
handleClose: () => void;
logId: string;
};
type ActionItemProps = {
actionLabel: string;
actionName: string;
};
export const Modal = ({ handleClose, logId }: ModalProps) => {
const { get } = useFetchClient();
const toggleNotification = useNotification();
const fetchAuditLog = async (id: string) => {
const { data } = await get<Get.Response>(`/admin/audit-logs/${id}`);
if (!data) {
throw new Error('Audit log not found');
}
return data;
};
const { data, status } = useQuery(['audit-log', logId], () => fetchAuditLog(logId), {
onError() {
toggleNotification({
type: 'warning',
message: { id: 'notification.error', defaultMessage: 'An error occured' },
});
handleClose();
},
});
const formatTimeStamp = useFormatTimeStamp();
const formattedDate = data && 'date' in data ? formatTimeStamp(data.date) : '';
return (
<ModalLayout onClose={handleClose} labelledBy="title">
<ModalHeader>
{/**
* TODO: this is not semantically correct and should be amended.
*/}
<Breadcrumbs label={formattedDate} id="title">
<Crumb isCurrent>{formattedDate}</Crumb>
</Breadcrumbs>
</ModalHeader>
<ModalBody>
<ActionBody status={status} data={data as AuditLog} formattedDate={formattedDate} />
</ModalBody>
</ModalLayout>
);
};
const ActionBody = ({ status, data, formattedDate }: ActionBodyProps) => {
const { formatMessage } = useIntl();
if (status === 'loading') {
return (
<Flex padding={7} justifyContent="center" alignItems="center">
{/**
* TODO: this will need to be translated.
*/}
<Loader>Loading content...</Loader>
</Flex>
);
}
const { action, user, payload } = data;
return (
<>
<Box marginBottom={3}>
<Typography variant="delta" id="title">
{formatMessage({
id: 'Settings.permissions.auditLogs.details',
defaultMessage: 'Log Details',
})}
</Typography>
</Box>
<Grid
gap={4}
gridCols={2}
paddingTop={4}
paddingBottom={4}
paddingLeft={6}
paddingRight={6}
marginBottom={4}
background="neutral100"
hasRadius
>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.action',
defaultMessage: 'Action',
})}
actionName={formatMessage(
{
id: `Settings.permissions.auditLogs.${action}`,
defaultMessage: getDefaultMessage(action as keyof typeof actionTypes),
},
// @ts-expect-error - any
{ model: payload?.model }
)}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.date',
defaultMessage: 'Date',
})}
actionName={formattedDate}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.user',
defaultMessage: 'User',
})}
actionName={user?.displayName || '-'}
/>
<ActionItem
actionLabel={formatMessage({
id: 'Settings.permissions.auditLogs.userId',
defaultMessage: 'User ID',
})}
actionName={user?.id.toString() || '-'}
/>
</Grid>
<JSONInput
value={JSON.stringify(payload, null, 2)}
disabled
label={formatMessage({
id: 'Settings.permissions.auditLogs.payload',
defaultMessage: 'Payload',
})}
/>
</>
);
};
const ActionItem = ({ actionLabel, actionName }: ActionItemProps) => {
return (
<Flex direction="column" alignItems="baseline" gap={1}>
<Typography textColor="neutral600" variant="sigma">
{actionLabel}
</Typography>
<Typography textColor="neutral600">{actionName}</Typography>
</Flex>
);
};

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Box, Flex } from '@strapi/design-system';
import { PageSizeURLQuery, PaginationURLQuery } from '@strapi/helper-plugin';
import { Pagination } from '../../../../../../../../shared/contracts/shared';
type PaginationFooterProps = {
pagination: Pagination;
};
export const PaginationFooter = (
{ pagination }: PaginationFooterProps = {
pagination: {
page: 1,
pageCount: 0,
pageSize: 50,
total: 0,
},
}
) => {
return (
<Box paddingTop={4}>
<Flex alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery />
<PaginationURLQuery pagination={pagination} />
</Flex>
</Box>
);
};

View File

@ -3,17 +3,37 @@ import React from 'react';
import { Flex, IconButton, Tbody, Td, Tr, Typography } from '@strapi/design-system';
import { onRowClick, stopPropagation } from '@strapi/helper-plugin';
import { Eye } from '@strapi/icons';
import { Attribute, Entity } from '@strapi/types';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import useFormatTimeStamp from '../hooks/useFormatTimeStamp';
import { ListLayoutRow } from '../../../../../../../../admin/src/content-manager/utils/layouts';
import { AuditLog } from '../../../../../../../../shared/contracts/audit-logs';
import { useFormatTimeStamp } from '../hooks/useFormatTimeStamp';
import { getDefaultMessage } from '../utils/getActionTypesDefaultMessages';
const TableRows = ({ headers, rows, onOpenModal }) => {
export interface TableHeader extends Omit<ListLayoutRow, 'metadatas' | 'fieldSchema' | 'name'> {
metadatas: Omit<ListLayoutRow['metadatas'], 'label'> & {
label: string;
};
name: keyof AuditLog;
fieldSchema?: Attribute.Any | { type: 'custom' };
cellFormatter?: (data?: AuditLog[keyof AuditLog]) => React.ReactNode;
}
type TableRowsProps = {
headers: TableHeader[];
rows: AuditLog[];
onOpenModal: (id: Entity.ID) => void;
};
export const TableRows = ({ headers, rows, onOpenModal }: TableRowsProps) => {
const { formatMessage } = useIntl();
const formatTimeStamp = useFormatTimeStamp();
const getCellValue = ({ type, value, model }) => {
// Not sure that 'value' can be typed properly
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getCellValue = ({ type, value, model }: { type: string; value: any; model: unknown }) => {
if (type === 'date') {
return formatTimeStamp(value);
}
@ -24,6 +44,7 @@ const TableRows = ({ headers, rows, onOpenModal }) => {
id: `Settings.permissions.auditLogs.${value}`,
defaultMessage: getDefaultMessage(value),
},
// @ts-expect-error - Model
{ model }
);
}
@ -41,13 +62,15 @@ const TableRows = ({ headers, rows, onOpenModal }) => {
fn: () => onOpenModal(data.id),
})}
>
{headers.map(({ key, name, cellFormatter }) => {
{headers?.map(({ key, name, cellFormatter }) => {
const rowValue = data[name];
return (
<Td key={key}>
<Typography textColor="neutral800">
{getCellValue({
type: key,
value: cellFormatter ? cellFormatter(data[name]) : data[name],
value: cellFormatter ? cellFormatter(rowValue) : rowValue,
model: data.payload?.model,
})}
</Typography>
@ -83,5 +106,3 @@ TableRows.propTypes = {
rows: PropTypes.array,
onOpenModal: PropTypes.func.isRequired,
};
export default TableRows;

View File

@ -1,16 +1,14 @@
import React from 'react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { fireEvent, render, screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { IntlProvider } from 'react-intl';
import { Router } from 'react-router-dom';
import TableRows from '..';
import { TableHeader, TableRows } from '../TableRows';
const history = createMemoryHistory();
const headers = [
const headers: TableHeader[] = [
{
name: 'action',
key: 'action',
@ -25,7 +23,8 @@ const headers = [
key: 'user',
name: 'user',
metadatas: { label: 'User', sortable: false },
cellFormatter: (value) => (value ? value.fullname : null),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cellFormatter: (value: any) => (value ? value.fullname : null),
},
];
@ -34,37 +33,57 @@ const rows = [
id: 1,
action: 'role.update',
date: '2022-11-14T23:04:00.000Z',
payload: {},
user: {
id: 1,
fullname: 'John Doe',
email: 'test@email.com',
displayName: 'John Doe',
isActive: true,
blocked: false,
createdAt: '',
updatedAt: '',
roles: [],
},
},
{
id: 2,
action: 'permission.create',
date: '2022-11-04T18:24:00.000Z',
payload: {},
user: {
id: 2,
fullname: 'Kai Doe',
email: 'test2@email.com',
displayName: 'Kai Doe',
isActive: true,
blocked: false,
createdAt: '',
updatedAt: '',
roles: [],
},
},
{
id: 3,
action: 'custom.action',
date: '2022-11-04T18:23:00.000Z',
payload: {},
user: {
id: 2,
fullname: 'Kai Doe',
email: 'test2@email.com',
displayName: 'Kai Doe',
isActive: true,
blocked: false,
createdAt: '',
updatedAt: '',
roles: [],
},
},
];
const onModalOpen = jest.fn();
// eslint-disable-next-line react/prop-types
const App = (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en" textComponent="span">
@ -96,14 +115,15 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView | Dynamic Table | Table Rows', (
it('should open a modal when clicked on a view details icon button', () => {
render(App);
const label = screen.getByText(/update action details/i);
// eslint-disable-next-line testing-library/no-node-access
const viewDetailsButton = label.closest('button');
fireEvent.click(viewDetailsButton);
if (viewDetailsButton) fireEvent.click(viewDetailsButton);
expect(onModalOpen).toHaveBeenCalled();
});
it('should open a modal when clicked on a row', () => {
it('should open a modal when clicked on a row', async () => {
render(App);
const rows = document.querySelectorAll('tr');
const rows = await screen.findAllByRole('row');
fireEvent.click(rows[0]);
expect(onModalOpen).toHaveBeenCalled();
});

View File

@ -2,9 +2,16 @@ import { useFetchClient, useNotification, useQueryParams } from '@strapi/helper-
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useAdminUsers } from '../../../../../../../../../admin/src/hooks/useAdminUsers';
import { useAdminUsers } from '../../../../../../../../admin/src/hooks/useAdminUsers';
import { GetAll } from '../../../../../../../../shared/contracts/audit-logs';
const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
export const useAuditLogsData = ({
canReadAuditLogs,
canReadUsers,
}: {
canReadAuditLogs: boolean;
canReadUsers: boolean;
}) => {
const { get } = useFetchClient();
const { search } = useLocation();
const toggleNotification = useNotification();
@ -14,7 +21,7 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
keepPreviousData: true,
retry: false,
staleTime: 1000 * 20, // 20 seconds
onError: (error) => toggleNotification({ type: 'warning', message: error.message }),
onError: (error: Error) => toggleNotification({ type: 'warning', message: error.message }),
};
const {
@ -37,7 +44,7 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
} = useQuery(
['auditLogs', search],
async () => {
const { data } = await get(`/admin/audit-logs`, {
const { data } = await get<GetAll.Response['data']>(`/admin/audit-logs`, {
params: query,
});
@ -56,5 +63,3 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
hasError: isAuditLogsError || isUsersError,
};
};
export default useAuditLogsData;

View File

@ -1,10 +1,10 @@
import parseISO from 'date-fns/parseISO';
import { useIntl } from 'react-intl';
const useFormatTimeStamp = () => {
export const useFormatTimeStamp = () => {
const { formatDate } = useIntl();
const formatTimeStamp = (value) => {
const formatTimeStamp = (value: string) => {
const date = parseISO(value);
const formattedDate = formatDate(date, {
@ -20,5 +20,3 @@ const useFormatTimeStamp = () => {
return formatTimeStamp;
};
export default useFormatTimeStamp;

View File

@ -1,5 +1,3 @@
import React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { fixtures } from '@strapi/admin-test-utils';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
@ -11,12 +9,14 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import useAuditLogsData from '../hooks/useAuditLogsData';
import ListView from '../index';
import { useAuditLogsData } from '../hooks/useAuditLogsData';
import { ListView } from '../ListPage';
import { getBigTestPageData, TEST_PAGE_DATA, TEST_SINGLE_DATA } from './utils/data';
jest.mock('../hooks/useAuditLogsData', () => jest.fn());
jest.mock('../hooks/useAuditLogsData', () => ({
useAuditLogsData: jest.fn(),
}));
const mockUseQuery = jest.fn();
@ -41,8 +41,8 @@ jest.mock('@strapi/helper-plugin', () => ({
const history = createMemoryHistory();
const setup = (props) => ({
...render(<ListView {...props} />, {
const setup = () => ({
...render(<ListView />, {
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
@ -105,6 +105,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
});
it('should render page with right header details', () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: [],
@ -121,6 +122,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
});
it('should show a list of audit logs with all actions', async () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: TEST_PAGE_DATA,
@ -128,12 +130,19 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
isLoading: false,
});
const { getByText } = setup();
setup();
await waitFor(() => expect(getByText('Create role')).toBeInTheDocument());
await waitFor(() => expect(getByText('Delete role')).toBeInTheDocument());
await waitFor(() => expect(getByText('Create entry (article)')).toBeInTheDocument());
await waitFor(() => expect(getByText('Admin logout')).toBeInTheDocument());
const createRoleEl = await screen.findByText('Create role');
expect(createRoleEl).toBeInTheDocument();
const deleteRoleEl = await screen.findByText('Delete role');
expect(deleteRoleEl).toBeInTheDocument();
const createEntryEl = await screen.findByText('Create entry (article)');
expect(createEntryEl).toBeInTheDocument();
const adminLogoutEl = await screen.findByText('Admin logout');
expect(adminLogoutEl).toBeInTheDocument();
});
it('should open a modal when clicked on a table row and close modal when clicked', async () => {
@ -152,8 +161,10 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
status: 'success',
});
// eslint-disable-next-line testing-library/no-node-access
const auditLogRow = getByText('Create role').closest('tr');
await user.click(auditLogRow);
if (auditLogRow) await user.click(auditLogRow);
const modal = screen.getByRole('dialog');
expect(modal).toBeInTheDocument();
@ -163,12 +174,16 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
expect(modalContainer.getByText('test user')).toBeInTheDocument();
expect(modalContainer.getAllByText('December 22, 2022, 16:11:03')).toHaveLength(2);
// eslint-disable-next-line testing-library/no-node-access
const closeButton = modalContainer.getByText(/close the modal/i).closest('button');
await user.click(closeButton);
if (closeButton) await user.click(closeButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should show pagination and be on page 1 on first render', async () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: getBigTestPageData(15),
@ -182,12 +197,17 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
isLoading: false,
});
const { getByText } = setup();
setup();
await waitFor(() => expect(getByText(/go to page 1/i).closest('a')).toHaveClass('active'));
const goToPageEl = await screen.findByText(/go to page 1/i);
// eslint-disable-next-line testing-library/no-node-access
const goToPageLink = goToPageEl.closest('a');
expect(goToPageLink).toHaveClass('active');
});
it('should paginate the results', async () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: getBigTestPageData(35),
@ -207,28 +227,37 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
// Should have pagination section with 4 pages
const pagination = getByLabelText(/pagination/i);
expect(pagination).toBeVisible();
// eslint-disable-next-line testing-library/no-node-access
const pageButtons = getAllByText(/go to page \d+/i).map((el) => el.closest('a'));
expect(pageButtons.length).toBe(4);
// Can't go to previous page since there isn't one
// eslint-disable-next-line testing-library/no-node-access
expect(getByText(/go to previous page/i).closest('a')).toHaveAttribute('aria-disabled', 'true');
// Can go to next page
await user.click(getByText(/go to next page/i).closest('a'));
// eslint-disable-next-line testing-library/no-node-access
const nextPageLinkEl = getByText(/go to next page/i).closest('a');
if (nextPageLinkEl) await user.click(nextPageLinkEl);
expect(history.location.search).toBe('?page=2');
// Can go to previous page
await user.click(getByText(/go to previous page/i).closest('a'));
// eslint-disable-next-line testing-library/no-node-access
const previousPageLinkEl = getByText(/go to previous page/i).closest('a');
if (previousPageLinkEl) await user.click(previousPageLinkEl);
expect(history.location.search).toBe('?page=1');
// Can go to specific page
await user.click(getByText(/go to page 3/i).closest('a'));
// eslint-disable-next-line testing-library/no-node-access
const thirdPageLinkEl = getByText(/go to page 3/i).closest('a');
if (thirdPageLinkEl) await user.click(thirdPageLinkEl);
expect(history.location.search).toBe('?page=3');
});
it('should show 20 elements if pageSize is 20', async () => {
history.location.search = '?pageSize=20';
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: getBigTestPageData(20),
@ -244,11 +273,17 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
const { container } = setup();
const rows = await waitFor(() => container.querySelector('tbody').querySelectorAll('tr'));
expect(rows.length).toEqual(20);
// eslint-disable-next-line testing-library/no-node-access
const tableRows = container.querySelector('tbody')?.querySelectorAll('tr');
if (tableRows) {
const rows = await waitFor(() => tableRows);
expect(rows.length).toEqual(20);
}
});
it('should show the correct inputs for filtering', async () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: TEST_PAGE_DATA,
@ -260,8 +295,8 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
const filtersButton = getByRole('button', { name: /filters/i });
await user.click(filtersButton);
const filterButton = getByLabelText(/select field/i, { name: 'action' });
const operatorButton = getByLabelText(/select filter/i, { name: 'is' });
const filterButton = getByLabelText(/select field/i);
const operatorButton = getByLabelText(/select filter/i);
const comboBoxInput = getByPlaceholderText(/select or enter a value/i);
const addFilterButton = getByRole('button', { name: /add filter/i });
@ -272,6 +307,7 @@ describe('ADMIN | Pages | AUDIT LOGS | ListView', () => {
});
it('should add filters to the query params', async () => {
// @ts-expect-error - mock
useAuditLogsData.mockReturnValue({
auditLogs: {
results: TEST_PAGE_DATA,

View File

@ -57,7 +57,7 @@ const TEST_SINGLE_DATA = {
},
};
const getBigTestPageData = (quantity) => {
const getBigTestPageData = (quantity: number) => {
const data = [];
for (let i = 0; i < quantity; i++) {

View File

@ -29,6 +29,6 @@ export const actionTypes = {
'permission.delete': 'Delete permission',
};
export const getDefaultMessage = (value) => {
export const getDefaultMessage = (value: keyof typeof actionTypes) => {
return actionTypes[value] || value;
};

View File

@ -1,4 +1,7 @@
import ComboboxFilter from '../ComboboxFilter';
import { IntlShape } from 'react-intl';
import { SanitizedAdminUser } from '../../../../../../../../shared/contracts/shared';
import { ComboboxFilter } from '../components/ComboboxFilter';
import { actionTypes, getDefaultMessage } from './getActionTypesDefaultMessages';
@ -13,8 +16,17 @@ const customOperators = [
},
];
const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
const actionOptions = Object.keys(actionTypes).map((action) => {
export const getDisplayedFilters = ({
formatMessage,
users,
canReadUsers,
}: {
formatMessage: IntlShape['formatMessage'];
users: SanitizedAdminUser[];
canReadUsers: boolean;
}) => {
// Default return of Object.keys function is string
const actionOptions = (Object.keys(actionTypes) as (keyof typeof actionTypes)[]).map((action) => {
return {
label: formatMessage(
{
@ -54,7 +66,7 @@ const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
];
if (canReadUsers && users) {
const getDisplayNameFromUser = (user) => {
const getDisplayNameFromUser = (user: SanitizedAdminUser) => {
if (user.username) {
return user.username;
}
@ -103,5 +115,3 @@ const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
return filters;
};
export default getDisplayedFilters;

View File

@ -1,4 +1,7 @@
const tableHeaders = [
import { SanitizedAdminUserForAuditLogs } from '../../../../../../../../shared/contracts/audit-logs';
import { TableHeader } from '../components/TableRows';
export const tableHeaders = [
{
name: 'action',
key: 'action',
@ -31,8 +34,7 @@ const tableHeaders = [
},
sortable: false,
},
cellFormatter: (user) => (user ? user.displayName : ''),
cellFormatter: (user: any) =>
user ? (user as SanitizedAdminUserForAuditLogs).displayName : '',
},
];
export default tableHeaders;
] as const;

View File

@ -1,19 +1,27 @@
import getDisplayedFilters from '../utils/getDisplayedFilters';
import { getDisplayedFilters } from '../getDisplayedFilters';
const mockUsers = [
{
id: 1,
firstname: 'test',
lastname: 'tester',
username: null,
email: 'test@test.com',
isActive: true,
blocked: false,
createdAt: '',
updatedAt: '',
roles: [],
},
{
id: 2,
firstname: 'test2',
lastname: 'tester2',
username: null,
email: 'test2@test.com',
isActive: true,
blocked: false,
createdAt: '',
updatedAt: '',
roles: [],
},
];
@ -21,6 +29,7 @@ describe('Audit Logs getDisplayedFilters', () => {
it('should return all filters when canReadUsers is true', () => {
const filters = getDisplayedFilters({
users: mockUsers,
// @ts-expect-error - mock
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
canReadUsers: true,
});
@ -31,6 +40,7 @@ describe('Audit Logs getDisplayedFilters', () => {
it('should not return user filter when canReadUsers is false', () => {
const filters = getDisplayedFilters({
users: mockUsers,
// @ts-expect-error - mock
formatMessage: jest.fn(({ defaultMessage }) => defaultMessage),
canReadUsers: false,
});

View File

@ -1,6 +1,11 @@
import { errors } from '@strapi/utils';
import { Entity, Pagination, SanitizedAdminUser } from './shared';
// displayName seems to be used only for audit logs
export interface SanitizedAdminUserForAuditLogs extends SanitizedAdminUser {
displayName: string;
}
interface AuditLog extends Pick<Entity, 'id'> {
date: string;
action: string;
@ -9,7 +14,7 @@ interface AuditLog extends Pick<Entity, 'id'> {
* However, we know it's JSON.
*/
payload: Record<string, unknown>;
user?: SanitizedAdminUser;
user?: SanitizedAdminUserForAuditLogs;
}
namespace GetAll {