1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-17 01:17:29 +02:00

Projects archive UI (#7842)

Closes
[issue/1-2666](https://linear.app/unleash/issue/1-2666/archived-projects-view)
This commit is contained in:
Tymoteusz Czech 2024-08-13 14:33:11 +02:00 committed by GitHub
parent 3c45a4b2a9
commit f2b7e0278d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 437 additions and 77 deletions

View File

@ -32,7 +32,7 @@ const BreadcrumbNav = () => {
const { isAdmin } = useContext(AccessContext);
const location = useLocation();
const paths = location.pathname
let paths = location.pathname
.split('/')
.filter((item) => item)
.filter(
@ -55,9 +55,15 @@ const BreadcrumbNav = () => {
.map(decodeURI);
if (location.pathname === '/insights') {
// Because of sticky header in Insights
return null;
}
if (paths.length === 1 && paths[0] === 'projects-archive') {
// It's not possible to use `projects/archive`, because it's :projectId path
paths = ['projects', 'archive'];
}
return (
<StyledBreadcrumbContainer>
<ConditionallyRender

View File

@ -1,6 +1,7 @@
import type { ComponentProps, FC } from 'react';
import { SvgIcon } from '@mui/material';
import { ReactComponent as Svg } from 'assets/icons/projectIconSmall.svg';
export const ProjectIcon = () => (
<SvgIcon component={Svg} viewBox={'0 0 14 10'} />
);
export const ProjectIcon: FC<ComponentProps<typeof SvgIcon>> = ({
...props
}) => <SvgIcon component={Svg} viewBox={'0 0 14 10'} {...props} />;

View File

@ -98,6 +98,13 @@ exports[`returns all baseRoutes 1`] = `
"title": "Projects",
"type": "protected",
},
{
"component": [Function],
"menu": {},
"path": "/projects-archive",
"title": "Projects archive",
"type": "protected",
},
{
"component": [Function],
"menu": {

View File

@ -9,6 +9,7 @@ import { NewUser } from 'component/user/NewUser/NewUser';
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
import { ProjectListNew } from 'component/project/ProjectList/ProjectList';
import { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
import RedirectArchive from 'component/archive/RedirectArchive';
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
import EditEnvironment from 'component/environments/EditEnvironment/EditEnvironment';
@ -125,6 +126,13 @@ export const routes: IRoute[] = [
type: 'protected',
menu: { mobile: true },
},
{
path: '/projects-archive',
title: 'Projects archive',
component: ArchiveProjectList,
type: 'protected',
menu: {},
},
// Features
{

View File

@ -4,22 +4,27 @@ import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import { flexRow } from 'themes/themeStyles';
export const StyledProjectCard = styled(Card)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
'&:hover': {
export const StyledProjectCard = styled(Card)<{ disabled?: boolean }>(
({ theme, disabled = false }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.neutral.light,
},
borderRadius: theme.shape.borderRadiusMedium,
}));
backgroundColor: disabled
? theme.palette.neutral.light
: theme.palette.background.default,
'&:hover': {
backgroundColor: theme.palette.neutral.light,
},
borderRadius: theme.shape.borderRadiusMedium,
}),
);
export const StyledProjectCardBody = styled(Box)(({ theme }) => ({
padding: theme.spacing(1, 2, 2, 2),
@ -72,11 +77,13 @@ export const StyledDivInfo = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 1),
}));
export const StyledParagraphInfo = styled('p')(({ theme }) => ({
color: theme.palette.primary.dark,
fontWeight: 'bold',
fontSize: theme.typography.body1.fontSize,
}));
export const StyledParagraphInfo = styled('p')<{ disabled?: boolean }>(
({ theme, disabled = false }) => ({
color: disabled ? 'inherit' : theme.palette.primary.dark,
fontWeight: disabled ? 'normal' : 'bold',
fontSize: theme.typography.body1.fontSize,
}),
);
export const StyledIconBox = styled(Box)(({ theme }) => ({
display: 'grid',
@ -87,3 +94,8 @@ export const StyledIconBox = styled(Box)(({ theme }) => ({
color: theme.palette.primary.main,
height: '100%',
}));
export const StyledActions = styled(Box)(({ theme }) => ({
display: 'flex',
marginRight: theme.spacing(2),
}));

View File

@ -20,7 +20,7 @@ interface IProjectCardProps {
name: string;
featureCount: number;
health: number;
memberCount: number;
memberCount?: number;
id: string;
onHover: () => void;
isFavorite?: boolean;
@ -32,7 +32,7 @@ export const ProjectCard = ({
name,
featureCount,
health,
memberCount,
memberCount = 0,
onHover,
id,
mode,

View File

@ -0,0 +1,140 @@
import type { FC } from 'react';
import {
StyledProjectCard,
StyledDivHeader,
StyledBox,
StyledCardTitle,
StyledDivInfo,
StyledParagraphInfo,
StyledProjectCardBody,
StyledIconBox,
StyledActions,
} from './NewProjectCard.styles';
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
import { ProjectOwners } from './ProjectOwners/ProjectOwners';
import type { ProjectSchemaOwners } from 'openapi';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import { formatDateYMDHM } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { parseISO } from 'date-fns';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import TimeAgo from 'react-timeago';
import { Box, Link, Tooltip } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import {
CREATE_PROJECT,
DELETE_PROJECT,
} from 'component/providers/AccessProvider/permissions';
import Undo from '@mui/icons-material/Undo';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import Delete from '@mui/icons-material/Delete';
interface IProjectArchiveCardProps {
id: string;
name: string;
createdAt?: string;
archivedAt?: string;
featureCount: number;
onRevive: () => void;
onDelete: () => void;
mode: string;
owners?: ProjectSchemaOwners;
}
export const ProjectArchiveCard: FC<IProjectArchiveCardProps> = ({
id,
name,
archivedAt,
featureCount = 0,
onRevive,
onDelete,
mode,
owners,
}) => {
const { locationSettings } = useLocationSettings();
const Actions: FC<{
id: string;
}> = ({ id }) => (
<StyledActions>
<PermissionIconButton
onClick={onRevive}
projectId={id}
permission={CREATE_PROJECT}
tooltipProps={{ title: 'Restore project' }}
data-testid={`revive-feature-flag-button`}
>
<Undo />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_PROJECT}
projectId={id}
tooltipProps={{ title: 'Permanently delete project' }}
onClick={onDelete}
>
<Delete />
</PermissionIconButton>
</StyledActions>
);
return (
<StyledProjectCard disabled>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledIconBox>
<ProjectIcon color='action' />
</StyledIconBox>
<StyledBox data-loading>
<StyledCardTitle>{name}</StyledCardTitle>
</StyledBox>
<ProjectModeBadge mode={mode} />
</StyledDivHeader>
<StyledDivInfo>
<Link
component={RouterLink}
to={`/archive?search=project%3A${encodeURI(id)}`}
>
<StyledParagraphInfo disabled data-loading>
{featureCount}
</StyledParagraphInfo>
<p data-loading>
archived {featureCount === 1 ? 'flag' : 'flags'}
</p>
</Link>
<ConditionallyRender
condition={Boolean(archivedAt)}
show={
<Tooltip
title={formatDateYMDHM(
parseISO(archivedAt as string),
locationSettings.locale,
)}
arrow
>
<Box
sx={(theme) => ({
color: theme.palette.text.secondary,
})}
>
<StyledParagraphInfo disabled data-loading>
Archived
</StyledParagraphInfo>
<p data-loading>
<TimeAgo
date={
new Date(archivedAt as string)
}
/>
</p>
</Box>
</Tooltip>
}
/>
</StyledDivInfo>
</StyledProjectCardBody>
<ProjectCardFooter id={id} Actions={Actions} disabled>
<ProjectOwners owners={owners} />
</ProjectCardFooter>
</StyledProjectCard>
);
};

View File

@ -10,26 +10,36 @@ interface IProjectCardFooterProps {
id: string;
isFavorite?: boolean;
children?: React.ReactNode;
Actions?: FC<{ id: string; isFavorite?: boolean }>;
disabled?: boolean;
}
const StyledFooter = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
const StyledFooter = styled(Box)<{ disabled: boolean }>(
({ theme, disabled }) => ({
display: 'flex',
background: disabled
? theme.palette.background.paper
: theme.palette.envAccordion.expanded,
boxShadow: theme.boxShadows.accordionFooter,
alignItems: 'center',
justifyContent: 'space-between',
borderTop: `1px solid ${theme.palette.divider}`,
}),
);
const StyledContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(1.5, 0, 2.5, 3),
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1.5, 3, 2.5, 3),
background: theme.palette.envAccordion.expanded,
boxShadow: theme.boxShadows.accordionFooter,
}));
const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
marginRight: theme.spacing(-1),
marginBottom: theme.spacing(-1),
margin: theme.spacing(1, 2, 0, 0),
}));
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
children,
const FavoriteAction: FC<{ id: string; isFavorite?: boolean }> = ({
id,
isFavorite = false,
isFavorite,
}) => {
const { setToastApiError } = useToast();
const { favorite, unfavorite } = useFavoriteProjectsApi();
@ -48,14 +58,27 @@ export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
setToastApiError('Something went wrong, could not update favorite');
}
};
return (
<StyledFooter>
{children}
<StyledFavoriteIconButton
onClick={onFavorite}
isFavorite={isFavorite}
size='medium'
/>
<StyledFavoriteIconButton
onClick={onFavorite}
isFavorite={Boolean(isFavorite)}
size='medium'
/>
);
};
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
children,
id,
isFavorite = false,
Actions = FavoriteAction,
disabled = false,
}) => {
return (
<StyledFooter disabled={disabled}>
<StyledContainer>{children}</StyledContainer>
<Actions id={id} isFavorite={isFavorite} />
</StyledFooter>
);
};

View File

@ -1,4 +1,4 @@
import type { VFC } from 'react';
import type { FC } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
@ -8,7 +8,7 @@ interface IProjectModeBadgeProps {
mode: 'private' | 'protected' | 'public' | string;
}
export const ProjectModeBadge: VFC<IProjectModeBadgeProps> = ({ mode }) => {
export const ProjectModeBadge: FC<IProjectModeBadgeProps> = ({ mode }) => {
if (mode === 'private') {
return (
<HtmlTooltip

View File

@ -0,0 +1,99 @@
import { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import useProjectsArchive from 'hooks/api/getters/useProjectsArchive/useProjectsArchive';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import ApiError from 'component/common/ApiError/ApiError';
import { styled, useMediaQuery } from '@mui/material';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { ProjectGroup } from './ProjectGroup';
import { ProjectArchiveCard } from '../NewProjectCard/ProjectArchiveCard';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
marginBottom: theme.spacing(2),
}));
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
}));
type PageQueryType = Partial<Record<'search', string>>;
export const ArchiveProjectList: FC = () => {
const { projects, loading, error, refetch } = useProjectsArchive();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
useEffect(() => {
const tableState: PageQueryType = {};
if (searchValue) {
tableState.search = searchValue;
}
setSearchParams(tableState, {
replace: true,
});
}, [searchValue, setSearchParams]);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Projects archive (${projects.length || 0})`}
actions={
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<StyledContainer>
<ConditionallyRender
condition={error}
show={() => (
<StyledApiError
onClick={refetch}
text='Error fetching projects'
/>
)}
/>
<ProjectGroup
loading={loading}
searchValue={searchValue}
projects={projects}
placeholder='No archived projects found'
ProjectCardComponent={ProjectArchiveCard}
link={false}
/>
</StyledContainer>
</PageContent>
);
};

View File

@ -1,3 +1,4 @@
import type { ComponentType } from 'react';
import { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectCard } from '../NewProjectCard/NewProjectCard';
@ -23,12 +24,25 @@ const StyledCardLink = styled(Link)(({ theme }) => ({
pointer: 'cursor',
}));
export const ProjectGroup: React.FC<{
type ProjectGroupProps<T extends { id: string } = IProjectCard> = {
sectionTitle?: string;
projects: IProjectCard[];
projects: T[];
loading: boolean;
searchValue: string;
}> = ({ sectionTitle, projects, loading, searchValue }) => {
placeholder?: string;
ProjectCardComponent?: ComponentType<T & any>;
link?: boolean;
};
export const ProjectGroup = <T extends { id: string }>({
sectionTitle,
projects,
loading,
searchValue,
placeholder = 'No projects available.',
ProjectCardComponent = ProjectCard,
link = true,
}: ProjectGroupProps<T>) => {
return (
<article>
<ConditionallyRender
@ -56,9 +70,7 @@ export const ProjectGroup: React.FC<{
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No projects available.
</TablePlaceholder>
<TablePlaceholder>{placeholder}</TablePlaceholder>
}
/>
}
@ -87,28 +99,24 @@ export const ProjectGroup: React.FC<{
)}
elseShow={() => (
<>
{projects.map((project: IProjectCard) => (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard
{projects.map((project: T) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCardComponent
onHover={() => {}}
{...project}
/>
</StyledCardLink>
) : (
<ProjectCardComponent
onHover={() => {}}
name={project.name}
mode={project.mode}
memberCount={
project.memberCount ?? 0
}
health={project.health}
id={project.id}
featureCount={
project.featureCount
}
isFavorite={project.favorite}
owners={project.owners}
{...project}
/>
</StyledCardLink>
))}
),
)}
</>
)}
/>

View File

@ -11,7 +11,8 @@ import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import Add from '@mui/icons-material/Add';
import ApiError from 'component/common/ApiError/ApiError';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { styled, useMediaQuery } from '@mui/material';
import { Link, styled, useMediaQuery } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
@ -24,6 +25,7 @@ import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import { groupProjects } from './group-projects';
import { ProjectGroup } from './ProjectGroup';
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
@ -38,10 +40,6 @@ const StyledContainer = styled('div')(({ theme }) => ({
type PageQueryType = Partial<Record<'search', string>>;
type projectMap = {
[index: string]: boolean;
};
interface ICreateButtonData {
disabled: boolean;
tooltip?: Omit<ITooltipResolverProps, 'children'>;
@ -128,6 +126,7 @@ export const ProjectListNew = () => {
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const archiveProjectsEnabled = useUiFlag('archiveProjects');
const myProjects = new Set(useProfile().profile?.projects || []);
@ -201,6 +200,21 @@ export const ProjectListNew = () => {
</>
}
/>
<ConditionallyRender
condition={Boolean(archiveProjectsEnabled)}
show={
<>
<Link
component={RouterLink}
to='/projects-archive'
>
Archived projects
</Link>
<PageHeader.Divider />
</>
}
/>
<ProjectCreationButton />
</>
}

View File

@ -0,0 +1,39 @@
import type { ProjectSchema } from 'openapi';
// FIXME: import tpye
interface IProjectArchiveCard {
name: string;
id: string;
createdAt: string;
archivedAt: string;
description: string;
featureCount: number;
owners?: ProjectSchema['owners'];
}
// TODO: implement data fetching
const useProjectsArchive = () => {
return {
projects: [
{
name: 'Archived something',
id: 'archi',
createdAt: new Date('2024-08-10 16:06').toISOString(),
archivedAt: new Date('2024-08-12 17:07').toISOString(),
owners: [{ ownerType: 'system' }],
},
{
name: 'Second example',
id: 'pid',
createdAt: new Date('2024-08-10 16:06').toISOString(),
archivedAt: new Date('2024-08-12 17:07').toISOString(),
owners: [{ ownerType: 'system' }],
},
],
error: undefined as any,
loading: false,
refetch: () => {},
};
};
export default useProjectsArchive;

View File

@ -29,6 +29,7 @@ const theme = {
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
separator: '0px 2px 4px rgba(32, 32, 33, 0.12)', // Notifications header
accordionFooter: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)',
reverseFooter: 'inset 0px -2px 4px rgba(32, 32, 33, 0.05)',
},
typography: {
fontFamily: 'Sen, Roboto, sans-serif',

View File

@ -21,6 +21,7 @@ export const theme = {
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
separator: '0px 2px 4px rgba(32, 32, 33, 0.12)', // Notifications header
accordionFooter: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)',
reverseFooter: 'inset 0px -2px 4px rgba(32, 32, 33, 0.05)',
},
typography: {
fontFamily: 'Sen, Roboto, sans-serif',

View File

@ -35,6 +35,7 @@ declare module '@mui/material/styles' {
primaryHeader: string;
separator: string;
accordionFooter: string;
reverseFooter: string;
};
}