From d48cfc8585ad41614a47ae26b8589b3377f3fcce Mon Sep 17 00:00:00 2001 From: andreas-unleash <104830839+andreas-unleash@users.noreply.github.com> Date: Fri, 28 Oct 2022 11:24:13 +0300 Subject: [PATCH] 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 --- .../common/Table/cells/TextCell/TextCell.tsx | 6 +- .../src/component/project/Project/Project.tsx | 16 +- .../ProjectSuggestedChanges.tsx | 33 ++ .../SuggestionsTabs/AvatarCell/AvatarCell.tsx | 13 + .../ChangesetActionCell.tsx | 12 + .../ChangesetStatusCell.tsx | 111 +++++++ .../ChangesetTitleCell/ChangesetTitleCell.tsx | 60 ++++ .../SuggestionsTabs/SuggestionsTabs.styles.ts | 15 + .../SuggestionsTabs/SuggestionsTabs.tsx | 291 ++++++++++++++++++ .../useProjectSuggestedChanges.ts | 27 ++ 10 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/ProjectSuggestedChanges.tsx create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/AvatarCell/AvatarCell.tsx create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetActionCell/ChangesetActionCell.tsx create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetStatusCell/ChangesetStatusCell.tsx create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetTitleCell/ChangesetTitleCell.tsx create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.styles.ts create mode 100644 frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.tsx create mode 100644 frontend/src/hooks/api/getters/useProjectSuggestedChanges/useProjectSuggestedChanges.ts diff --git a/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx index 10335a76de..2e9f4cd15d 100644 --- a/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx +++ b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx @@ -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; } export const TextCell: FC = ({ value, children, lineClamp, + sx, 'data-testid': testid, }) => { const { classes } = useStyles({ lineClamp }); return ( - + {children ?? value} diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 4c003fa5ce..92eab90296 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -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 = () => { } /> } /> } /> + } + /> + } + /> { /> } /> - } /> diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/ProjectSuggestedChanges.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/ProjectSuggestedChanges.tsx new file mode 100644 index 0000000000..26506fa705 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/ProjectSuggestedChanges.tsx @@ -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 = { 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 ( + + ); +}; diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/AvatarCell/AvatarCell.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/AvatarCell/AvatarCell.tsx new file mode 100644 index 0000000000..ae4c5bd50a --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/AvatarCell/AvatarCell.tsx @@ -0,0 +1,13 @@ +import { UserAvatar } from '../../../../common/UserAvatar/UserAvatar'; +import { TextCell } from '../../../../common/Table/cells/TextCell/TextCell'; + +export const AvatarCell = ({ value }: any) => { + return ( + + + + ); +}; diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetActionCell/ChangesetActionCell.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetActionCell/ChangesetActionCell.tsx new file mode 100644 index 0000000000..3cb85e9945 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetActionCell/ChangesetActionCell.tsx @@ -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 ( + + {' '} + + ); +}; diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetStatusCell/ChangesetStatusCell.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetStatusCell/ChangesetStatusCell.tsx new file mode 100644 index 0000000000..6b9f3c9994 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetStatusCell/ChangesetStatusCell.tsx @@ -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 ( + } + /> + ); + case SuggestChangesetState.APPROVED: + return ( + } + /> + ); + case SuggestChangesetState.APPLIED: + return ( + } + /> + ); + case SuggestChangesetState.CANCELLED: + return ( + } + /> + ); + case SuggestChangesetState.REJECTED: + return ( + } + /> + ); + default: + return null; + } + }; + + if (!value) { + return ; + } + + return {renderState(value)}; +}; diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetTitleCell/ChangesetTitleCell.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetTitleCell/ChangesetTitleCell.tsx new file mode 100644 index 0000000000..df4dd0e128 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/ChangesetTitleCell/ChangesetTitleCell.tsx @@ -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 ; + } + + return ( + + + + Suggestion + + + {`#${id}`} + + + + {`${changes?.length}`} + + {changes.length < 1 ? `update` : 'updates'} + + + + ); +}; diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.styles.ts b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.styles.ts new file mode 100644 index 0000000000..c6f0c8fc97 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.styles.ts @@ -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, + }, + }, +})); diff --git a/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.tsx b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.tsx new file mode 100644 index 0000000000..a142a301e1 --- /dev/null +++ b/frontend/src/component/suggest-changes/ProjectSuggestions/SuggestionsTabs/SuggestionsTabs.tsx @@ -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; + setStoredParams: ( + newValue: + | SortingRule + | ((prev: SortingRule) => SortingRule) + ) => SortingRule; + 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 = {}; + 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 ( +
+ + {tabs.map(tab => ( + setActiveTab(tab)} + className={classes.tabButton} + /> + ))} + +
+ ); + }; + + return ( + + } + /> + } + > + + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+
+ ( + 0} + show={ + + No changes found matching “ + {searchValue}” + + } + elseShow={ + + None of the changes where submitted yet. + + } + /> + )} + /> +
+ ); +}; diff --git a/frontend/src/hooks/api/getters/useProjectSuggestedChanges/useProjectSuggestedChanges.ts b/frontend/src/hooks/api/getters/useProjectSuggestedChanges/useProjectSuggestedChanges.ts new file mode 100644 index 0000000000..f58e12aea7 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectSuggestedChanges/useProjectSuggestedChanges.ts @@ -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] + ); +};