1
0
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:
andreas-unleash 2022-10-28 11:24:13 +03:00 committed by GitHub
parent d2324ee91f
commit d48cfc8585
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 581 additions and 3 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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,
},
},
}));

View File

@ -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 &ldquo;
{searchValue}&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
None of the changes where submitted yet.
</TablePlaceholder>
}
/>
)}
/>
</PageContent>
);
};

View File

@ -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]
);
};