diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 1eaf61e7a844..f022381f6cce 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -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 ( ( - -); +export const ProjectIcon: FC> = ({ + ...props +}) => ; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index cf23befa0e4e..9f3ab5c999c2 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -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": { diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index d023d3b3cbd6..b7901e015719 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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 { diff --git a/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts b/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts index 6e9e7e970ea9..c0ef308b9436 100644 --- a/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts +++ b/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts @@ -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), +})); diff --git a/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx b/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx index 23198c0502dd..f4ba5ce84c58 100644 --- a/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx +++ b/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx @@ -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, diff --git a/frontend/src/component/project/NewProjectCard/ProjectArchiveCard.tsx b/frontend/src/component/project/NewProjectCard/ProjectArchiveCard.tsx new file mode 100644 index 000000000000..b00862630aed --- /dev/null +++ b/frontend/src/component/project/NewProjectCard/ProjectArchiveCard.tsx @@ -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 = ({ + id, + name, + archivedAt, + featureCount = 0, + onRevive, + onDelete, + mode, + owners, +}) => { + const { locationSettings } = useLocationSettings(); + const Actions: FC<{ + id: string; + }> = ({ id }) => ( + + + + + + + + + ); + + return ( + + + + + + + + {name} + + + + + + + {featureCount} + +

+ archived {featureCount === 1 ? 'flag' : 'flags'} +

+ + + ({ + color: theme.palette.text.secondary, + })} + > + + Archived + +

+ +

+
+ + } + /> +
+
+ + + +
+ ); +}; diff --git a/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx b/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx index 380024a895bd..b64c60ce9761 100644 --- a/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx +++ b/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx @@ -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 = ({ - 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 = ({ setToastApiError('Something went wrong, could not update favorite'); } }; + + return ( + + ); +}; + +export const ProjectCardFooter: FC = ({ + children, + id, + isFavorite = false, + Actions = FavoriteAction, + disabled = false, +}) => { return ( - - {children} - + + {children} + ); }; diff --git a/frontend/src/component/project/NewProjectCard/ProjectModeBadge/ProjectModeBadge.tsx b/frontend/src/component/project/NewProjectCard/ProjectModeBadge/ProjectModeBadge.tsx index e1b64ee430b5..f3e46ea67d9f 100644 --- a/frontend/src/component/project/NewProjectCard/ProjectModeBadge/ProjectModeBadge.tsx +++ b/frontend/src/component/project/NewProjectCard/ProjectModeBadge/ProjectModeBadge.tsx @@ -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 = ({ mode }) => { +export const ProjectModeBadge: FC = ({ mode }) => { if (mode === 'private') { return ( ({ + maxWidth: '500px', + marginBottom: theme.spacing(2), +})); + +const StyledContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(4), +})); + +type PageQueryType = Partial>; + +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 ( + + } + /> + } + > + + } + /> + + } + > + + ( + + )} + /> + + + + + ); +}; diff --git a/frontend/src/component/project/ProjectList/ProjectGroup.tsx b/frontend/src/component/project/ProjectList/ProjectGroup.tsx index 2aa12fb55f41..eaae9481eafe 100644 --- a/frontend/src/component/project/ProjectList/ProjectGroup.tsx +++ b/frontend/src/component/project/ProjectList/ProjectGroup.tsx @@ -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 = { sectionTitle?: string; - projects: IProjectCard[]; + projects: T[]; loading: boolean; searchValue: string; -}> = ({ sectionTitle, projects, loading, searchValue }) => { + placeholder?: string; + ProjectCardComponent?: ComponentType; + link?: boolean; +}; + +export const ProjectGroup = ({ + sectionTitle, + projects, + loading, + searchValue, + placeholder = 'No projects available.', + ProjectCardComponent = ProjectCard, + link = true, +}: ProjectGroupProps) => { return (
} elseShow={ - - No projects available. - + {placeholder} } /> } @@ -87,28 +99,24 @@ export const ProjectGroup: React.FC<{ )} elseShow={() => ( <> - {projects.map((project: IProjectCard) => ( - - + link ? ( + + {}} + {...project} + /> + + ) : ( + {}} - 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} /> - - ))} + ), + )} )} /> diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index e429978fbaea..1e9634e9469b 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -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>; -type projectMap = { - [index: string]: boolean; -}; - interface ICreateButtonData { disabled: boolean; tooltip?: Omit; @@ -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 = () => { } /> + + + Archived projects + + + + } + /> + } diff --git a/frontend/src/hooks/api/getters/useProjectsArchive/useProjectsArchive.ts b/frontend/src/hooks/api/getters/useProjectsArchive/useProjectsArchive.ts new file mode 100644 index 000000000000..57626243bd2d --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectsArchive/useProjectsArchive.ts @@ -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; diff --git a/frontend/src/themes/dark-theme.ts b/frontend/src/themes/dark-theme.ts index 72af6e3381d7..45f0db4a0593 100644 --- a/frontend/src/themes/dark-theme.ts +++ b/frontend/src/themes/dark-theme.ts @@ -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', diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index 5eec5cbf2a55..76037f524a5d 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -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', diff --git a/frontend/src/themes/themeTypes.ts b/frontend/src/themes/themeTypes.ts index a7b7fe593972..79e59a06a694 100644 --- a/frontend/src/themes/themeTypes.ts +++ b/frontend/src/themes/themeTypes.ts @@ -35,6 +35,7 @@ declare module '@mui/material/styles' { primaryHeader: string; separator: string; accordionFooter: string; + reverseFooter: string; }; }