mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
Feat/frontend changeset list (#2264)
* ChangesetTable initial * ChangesetTable bug fixes * Added tabs * Add Applied and Cancelled badges * fix alignment * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * replace updatedAt with createdAt * bug fix * bug fix
This commit is contained in:
parent
d2324ee91f
commit
d48cfc8585
@ -1,23 +1,25 @@
|
||||
import { FC } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, SxProps, Theme } from '@mui/material';
|
||||
import { useStyles } from './TextCell.styles';
|
||||
|
||||
interface ITextCellProps {
|
||||
value?: string | null;
|
||||
lineClamp?: number;
|
||||
'data-testid'?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const TextCell: FC<ITextCellProps> = ({
|
||||
value,
|
||||
children,
|
||||
lineClamp,
|
||||
sx,
|
||||
'data-testid': testid,
|
||||
}) => {
|
||||
const { classes } = useStyles({ lineClamp });
|
||||
|
||||
return (
|
||||
<Box className={classes.wrapper}>
|
||||
<Box className={classes.wrapper} sx={sx}>
|
||||
<span data-loading="true" data-testid={testid}>
|
||||
{children ?? value}
|
||||
</span>
|
||||
|
@ -27,6 +27,7 @@ import { ProjectLog } from './ProjectLog/ProjectLog';
|
||||
import { SuggestedChangeOverview } from 'component/suggestChanges/SuggestedChangeOverview/SuggestedChangeOverview';
|
||||
import { DraftBanner } from 'component/suggestChanges/DraftBanner/DraftBanner';
|
||||
import { MainLayout } from 'component/layout/MainLayout/MainLayout';
|
||||
import { ProjectSuggestedChanges } from '../../suggest-changes/ProjectSuggestions/ProjectSuggestedChanges';
|
||||
|
||||
const StyledDiv = styled('div')(() => ({
|
||||
display: 'flex',
|
||||
@ -88,6 +89,11 @@ const Project = () => {
|
||||
path: `${basePath}/archive`,
|
||||
name: 'archive',
|
||||
},
|
||||
{
|
||||
title: 'Change requests',
|
||||
path: `${basePath}/suggest-changes`,
|
||||
name: 'suggest-changes' + '',
|
||||
},
|
||||
{
|
||||
title: 'Event log',
|
||||
path: `${basePath}/logs`,
|
||||
@ -228,6 +234,15 @@ const Project = () => {
|
||||
<Route path="environments" element={<ProjectEnvironment />} />
|
||||
<Route path="archive" element={<ProjectFeaturesArchive />} />
|
||||
<Route path="logs" element={<ProjectLog />} />
|
||||
<Route
|
||||
path="suggest-changes"
|
||||
element={
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.suggestChanges)}
|
||||
show={<ProjectSuggestedChanges />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="suggest-changes/:id"
|
||||
element={
|
||||
@ -237,7 +252,6 @@ const Project = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<ProjectOverview />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
@ -0,0 +1,33 @@
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
|
||||
import { SuggestionsTabs } from './SuggestionsTabs/SuggestionsTabs';
|
||||
import { SortingRule } from 'react-table';
|
||||
import { useProjectSuggestedChanges } from 'hooks/api/getters/useProjectSuggestedChanges/useProjectSuggestedChanges';
|
||||
|
||||
const defaultSort: SortingRule<string> = { id: 'updatedAt', desc: true };
|
||||
|
||||
export const ProjectSuggestedChanges = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const projectName = useProjectNameOrId(projectId);
|
||||
|
||||
usePageTitle(`Change requests – ${projectName}`);
|
||||
|
||||
const { changesets, loading } = useProjectSuggestedChanges(projectId);
|
||||
|
||||
const { value, setValue } = createLocalStorage(
|
||||
`${projectId}:ProjectSuggestedChanges`,
|
||||
defaultSort
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsTabs
|
||||
changesets={changesets}
|
||||
storedParams={value}
|
||||
setStoredParams={setValue}
|
||||
projectId={projectId}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
import { UserAvatar } from '../../../../common/UserAvatar/UserAvatar';
|
||||
import { TextCell } from '../../../../common/Table/cells/TextCell/TextCell';
|
||||
|
||||
export const AvatarCell = ({ value }: any) => {
|
||||
return (
|
||||
<TextCell>
|
||||
<UserAvatar
|
||||
user={value}
|
||||
sx={{ maxWidth: '30px', maxHeight: '30px', alignSelf: 'left' }}
|
||||
/>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
import { ArrowRight } from '@mui/icons-material';
|
||||
import { useTheme } from '@mui/system';
|
||||
import { TextCell } from '../../../../common/Table/cells/TextCell/TextCell';
|
||||
|
||||
export const ChangesetActionCell = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<TextCell sx={{ textAlign: 'right' }}>
|
||||
<ArrowRight sx={{ color: theme.palette.secondary.main }} />{' '}
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,111 @@
|
||||
import { Chip, styled } from '@mui/material';
|
||||
import { colors } from '../../../../../themes/colors';
|
||||
import { TextCell } from '../../../../common/Table/cells/TextCell/TextCell';
|
||||
import { Check, CircleOutlined, Close } from '@mui/icons-material';
|
||||
|
||||
interface IChangesetStatusCellProps {
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
export enum SuggestChangesetState {
|
||||
DRAFT = 'Draft',
|
||||
APPROVED = 'Approved',
|
||||
IN_REVIEW = 'In review',
|
||||
APPLIED = 'Applied',
|
||||
CANCELLED = 'Cancelled',
|
||||
REJECTED = 'Rejected',
|
||||
}
|
||||
|
||||
export const StyledChip = styled(Chip)(({ theme, icon }) => ({
|
||||
padding: theme.spacing(0, 1),
|
||||
height: 30,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
gap: theme.spacing(1, 1),
|
||||
['& .MuiChip-label']: {
|
||||
padding: 0,
|
||||
paddingLeft: Boolean(icon) ? theme.spacing(0.5) : 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledRejectedChip = styled(StyledChip)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.error.main}`,
|
||||
backgroundColor: colors.red['100'],
|
||||
['& .MuiChip-label']: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
['& .MuiChip-icon']: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledApprovedChip = styled(StyledChip)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.success.main}`,
|
||||
backgroundColor: colors.green['100'],
|
||||
['& .MuiChip-label']: {
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
['& .MuiChip-icon']: {
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledReviewChip = styled(StyledChip)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.primary.main}`,
|
||||
backgroundColor: colors.purple['100'],
|
||||
['& .MuiChip-label']: {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
['& .MuiChip-icon']: {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
export const ChangesetStatusCell = ({ value }: IChangesetStatusCellProps) => {
|
||||
const renderState = (state: string) => {
|
||||
switch (state) {
|
||||
case SuggestChangesetState.IN_REVIEW:
|
||||
return (
|
||||
<StyledReviewChip
|
||||
label={'Review required'}
|
||||
icon={<CircleOutlined fontSize={'small'} />}
|
||||
/>
|
||||
);
|
||||
case SuggestChangesetState.APPROVED:
|
||||
return (
|
||||
<StyledApprovedChip
|
||||
label={'Approved'}
|
||||
icon={<Check fontSize={'small'} />}
|
||||
/>
|
||||
);
|
||||
case SuggestChangesetState.APPLIED:
|
||||
return (
|
||||
<StyledApprovedChip
|
||||
label={'Applied'}
|
||||
icon={<Check fontSize={'small'} />}
|
||||
/>
|
||||
);
|
||||
case SuggestChangesetState.CANCELLED:
|
||||
return (
|
||||
<StyledRejectedChip
|
||||
label={'Cancelled'}
|
||||
icon={<Close fontSize={'small'} sx={{ mr: 8 }} />}
|
||||
/>
|
||||
);
|
||||
case SuggestChangesetState.REJECTED:
|
||||
return (
|
||||
<StyledRejectedChip
|
||||
label={'Rejected'}
|
||||
icon={<Close fontSize={'small'} sx={{ mr: 8 }} />}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!value) {
|
||||
return <TextCell />;
|
||||
}
|
||||
|
||||
return <TextCell>{renderState(value)}</TextCell>;
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
import { TextCell } from '../../../../common/Table/cells/TextCell/TextCell';
|
||||
import { Link, styled, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useTheme } from '@mui/system';
|
||||
|
||||
interface IChangesetTitleCellProps {
|
||||
value?: any;
|
||||
row: { original: any };
|
||||
}
|
||||
|
||||
export const StyledLink = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
export const ChangesetTitleCell = ({
|
||||
value,
|
||||
row: { original },
|
||||
}: IChangesetTitleCellProps) => {
|
||||
const { id, features: changes, project } = original;
|
||||
const theme = useTheme();
|
||||
const path = `projects/${project}/suggest-changes/${id}`;
|
||||
|
||||
if (!value) {
|
||||
return <TextCell />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<StyledLink>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
underline={'hover'}
|
||||
to={path}
|
||||
sx={{ pt: 0.2 }}
|
||||
>
|
||||
Suggestion
|
||||
</Link>
|
||||
<Typography
|
||||
component={'span'}
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ margin: theme.spacing(0, 1), pt: 0 }}
|
||||
>
|
||||
{`#${id}`}
|
||||
</Typography>
|
||||
</StyledLink>
|
||||
<StyledLink>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
underline={'hover'}
|
||||
to={path}
|
||||
>{`${changes?.length}`}</Link>
|
||||
<span style={{ margin: 'auto 8px' }}>
|
||||
{changes.length < 1 ? `update` : 'updates'}
|
||||
</span>
|
||||
</StyledLink>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
tabContainer: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
tabButton: {
|
||||
textTransform: 'none',
|
||||
width: 'auto',
|
||||
fontSize: '1rem',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
minWidth: 160,
|
||||
},
|
||||
},
|
||||
}));
|
@ -0,0 +1,291 @@
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import {
|
||||
SortableTableHeader,
|
||||
Table,
|
||||
TableCell,
|
||||
TablePlaceholder,
|
||||
} from 'component/common/Table';
|
||||
import { SortingRule, useSortBy, useTable } from 'react-table';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { Tab, Tabs, useMediaQuery } from '@mui/material';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
|
||||
import theme from 'themes/theme';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { TimeAgoCell } from '../../../common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||
import { TextCell } from '../../../common/Table/cells/TextCell/TextCell';
|
||||
import { ChangesetStatusCell } from './ChangesetStatusCell/ChangesetStatusCell';
|
||||
import { ChangesetActionCell } from './ChangesetActionCell/ChangesetActionCell';
|
||||
import { AvatarCell } from './AvatarCell/AvatarCell';
|
||||
import { ChangesetTitleCell } from './ChangesetTitleCell/ChangesetTitleCell';
|
||||
import { TableBody, TableRow } from '../../../common/Table';
|
||||
import { useStyles } from './SuggestionsTabs.styles';
|
||||
|
||||
export interface IChangeSetTableProps {
|
||||
changesets: any[];
|
||||
loading: boolean;
|
||||
storedParams: SortingRule<string>;
|
||||
setStoredParams: (
|
||||
newValue:
|
||||
| SortingRule<string>
|
||||
| ((prev: SortingRule<string>) => SortingRule<string>)
|
||||
) => SortingRule<string>;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const SuggestionsTabs = ({
|
||||
changesets = [],
|
||||
loading,
|
||||
storedParams,
|
||||
setStoredParams,
|
||||
projectId,
|
||||
}: IChangeSetTableProps) => {
|
||||
const { classes } = useStyles();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || ''
|
||||
);
|
||||
|
||||
const [openChangesets, closedChangesets] = useMemo(() => {
|
||||
const open = changesets.filter(
|
||||
changeset =>
|
||||
changeset.state !== 'Cancelled' && changeset.state !== 'Applied'
|
||||
);
|
||||
const closed = changesets.filter(
|
||||
changeset =>
|
||||
changeset.state === 'Cancelled' || changeset.state === 'Applied'
|
||||
);
|
||||
|
||||
return [open, closed];
|
||||
}, [changesets]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Suggestions',
|
||||
data: openChangesets,
|
||||
},
|
||||
{
|
||||
title: 'Closed',
|
||||
data: closedChangesets,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState(tabs[0]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'Title',
|
||||
Header: 'Title',
|
||||
width: 100,
|
||||
canSort: true,
|
||||
accessor: 'id',
|
||||
Cell: ChangesetTitleCell,
|
||||
},
|
||||
{
|
||||
Header: 'By',
|
||||
accessor: 'createdBy',
|
||||
maxWidth: 50,
|
||||
canSort: false,
|
||||
Cell: AvatarCell,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
Header: 'Submitted',
|
||||
accessor: 'createdAt',
|
||||
searchable: true,
|
||||
maxWidth: 100,
|
||||
Cell: TimeAgoCell,
|
||||
sortType: 'alphanumeric',
|
||||
},
|
||||
{
|
||||
Header: 'Environment',
|
||||
accessor: 'environment',
|
||||
maxWidth: 100,
|
||||
Cell: TextCell,
|
||||
sortType: 'text',
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'state',
|
||||
minWidth: 150,
|
||||
width: 150,
|
||||
Cell: ChangesetStatusCell,
|
||||
sortType: 'text',
|
||||
},
|
||||
{
|
||||
Header: '',
|
||||
id: 'Actions',
|
||||
minWidth: 50,
|
||||
width: 50,
|
||||
canSort: false,
|
||||
Cell: ChangesetActionCell,
|
||||
},
|
||||
],
|
||||
//eslint-disable-next-line
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, activeTab.data);
|
||||
|
||||
const data = useMemo(
|
||||
() => (loading ? featuresPlaceholder : searchedData),
|
||||
[searchedData, loading]
|
||||
);
|
||||
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [
|
||||
{
|
||||
id: searchParams.get('sort') || storedParams.id,
|
||||
desc: searchParams.has('order')
|
||||
? searchParams.get('order') === 'desc'
|
||||
: storedParams.desc,
|
||||
},
|
||||
],
|
||||
hiddenColumns: [],
|
||||
}));
|
||||
|
||||
const {
|
||||
headerGroups,
|
||||
rows,
|
||||
state: { sortBy },
|
||||
prepareRow,
|
||||
setHiddenColumns,
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
} = useTable(
|
||||
{
|
||||
columns: columns as any[], // TODO: fix after `react-table` v8 update
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
disableSortRemove: true,
|
||||
autoResetSortBy: false,
|
||||
defaultColumn: {
|
||||
Cell: TextCell,
|
||||
},
|
||||
},
|
||||
useSortBy
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenColumns = [''];
|
||||
if (isSmallScreen) {
|
||||
hiddenColumns.push('createdBy', 'updatedAt');
|
||||
}
|
||||
setHiddenColumns(hiddenColumns);
|
||||
}, [setHiddenColumns, isSmallScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
const tableState: Record<string, string> = {};
|
||||
tableState.sort = sortBy[0].id;
|
||||
if (sortBy[0].desc) {
|
||||
tableState.order = 'desc';
|
||||
}
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||
}, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const renderTabs = () => {
|
||||
return (
|
||||
<div className={classes.tabContainer}>
|
||||
<Tabs
|
||||
value={activeTab?.title}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
{tabs.map(tab => (
|
||||
<Tab
|
||||
key={tab.title}
|
||||
label={`${tab.title} (${tab.data.length})`}
|
||||
value={tab.title}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={classes.tabButton}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
titleElement={renderTabs()}
|
||||
actions={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<Table {...getTableProps()}>
|
||||
<SortableTableHeader headerGroups={headerGroups} />
|
||||
<TableBody {...getTableBodyProps()}>
|
||||
{rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<TableRow hover {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<TableCell
|
||||
{...cell.getCellProps()}
|
||||
padding="none"
|
||||
>
|
||||
{cell.render('Cell')}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={() => (
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No changes found matching “
|
||||
{searchValue}”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
None of the changes where submitted yet.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
import useSWR from 'swr';
|
||||
import { useMemo } from 'react';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
|
||||
const fetcher = (path: string) => {
|
||||
return fetch(path)
|
||||
.then(handleErrorResponses('SuggestedChanges'))
|
||||
.then(res => res.json());
|
||||
};
|
||||
|
||||
export const useProjectSuggestedChanges = (project: string) => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
formatApiPath(`api/admin/projects/${project}/suggest-changes`),
|
||||
fetcher
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
changesets: data,
|
||||
loading: !error && !data,
|
||||
refetch: () => mutate(),
|
||||
error,
|
||||
}),
|
||||
[data, error, mutate]
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user