1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

chore: project list table view (#10466)

https://linear.app/unleash/issue/2-3740/implement-the-project-list-view

Implements the list (table) view of the projects page.

<img width="1300" height="956" alt="image"
src="https://github.com/user-attachments/assets/603bc9a8-21a9-4888-8804-1be23e2d63ee"
/>

<img width="1300" height="681" alt="image"
src="https://github.com/user-attachments/assets/67f6e8e9-cedf-4a70-ba95-b9c73e8d29a8"
/>
This commit is contained in:
Nuno Góis 2025-08-06 11:36:21 +01:00 committed by GitHub
parent 837c49e4a1
commit 44650e4e2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 218 additions and 49 deletions

View File

@ -25,7 +25,8 @@ const StyledWrapper = styled(Box, {
}));
const StyledSpan = styled('span')(() => ({
display: 'inline-block',
display: 'inline-flex',
flexDirection: 'column',
maxWidth: '100%',
}));

View File

@ -1,12 +1,13 @@
import type { ComponentType, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectCard as DefaultProjectCard } from '../ProjectCard/ProjectCard.tsx';
import type { ProjectSchema } from 'openapi';
import loadingData from './loadingData.ts';
import { loadingData } from './loadingData.ts';
import { styled } from '@mui/material';
import { UpgradeProjectCard } from '../ProjectCard/UpgradeProjectCard.tsx';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import type { ProjectsListView } from './hooks/useProjectsListState.ts';
import { ProjectsListTable } from './ProjectsListTable/ProjectsListTable.tsx';
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
@ -33,6 +34,7 @@ type ProjectGroupProps = {
placeholder?: string;
ProjectCardComponent?: ComponentType<ProjectSchema & any>;
link?: boolean;
view?: ProjectsListView;
};
export const ProjectGroup = ({
@ -40,54 +42,31 @@ export const ProjectGroup = ({
loading,
ProjectCardComponent,
link = true,
view = 'cards',
}: ProjectGroupProps) => {
const ProjectCard = ProjectCardComponent ?? DefaultProjectCard;
const { isOss } = useUiConfig();
const projectsToRender = loading ? loadingData : projects;
if (!isOss() && view === 'list') {
return <ProjectsListTable projects={projectsToRender} />;
}
return (
<StyledGridContainer>
<ConditionallyRender
condition={loading}
show={() => (
<>
{loadingData.map((project: ProjectSchema) => (
<ProjectCard
data-loading
createdAt={project.createdAt}
key={project.id}
name={project.name}
id={project.id}
mode={project.mode}
memberCount={2}
health={95}
featureCount={4}
owners={[
{
ownerType: 'user',
name: 'Loading data',
},
]}
/>
))}
</>
)}
elseShow={() => (
<>
{projects.map((project) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard {...project} />
</StyledCardLink>
) : (
<ProjectCard key={project.id} {...project} />
),
)}
</>
)}
/>
{projectsToRender.map((project) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard data-loading {...project} />
</StyledCardLink>
) : (
<ProjectCard data-loading key={project.id} {...project} />
),
)}
{isOss() ? <UpgradeProjectCard /> : null}
</StyledGridContainer>
);

View File

@ -30,7 +30,7 @@ const StyledApiError = styled(ApiError)(({ theme }) => ({
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(6),
gap: theme.spacing(4),
}));
export const ProjectList = () => {
@ -154,6 +154,7 @@ export const ProjectList = () => {
</ProjectsListHeader>
<ProjectGroup
loading={loading}
view={state.view}
projects={
isOss()
? sortedProjects
@ -169,6 +170,7 @@ export const ProjectList = () => {
</ProjectsListHeader>
<ProjectGroup
loading={loading}
view={state.view}
projects={otherProjects}
/>
</div>

View File

@ -11,6 +11,7 @@ type ProjectsListHeaderProps = {
const StyledHeaderContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column-reverse',
minHeight: theme.spacing(5),
gap: theme.spacing(2),
[theme.breakpoints.up('md')]: {
flexDirection: 'row',

View File

@ -0,0 +1,136 @@
import { VirtualizedTable } from 'component/common/Table';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { ProjectOwners } from 'component/project/ProjectCard/ProjectCardFooter/ProjectOwners/ProjectOwners';
import { ProjectLastSeen } from 'component/project/ProjectCard/ProjectLastSeen/ProjectLastSeen';
import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import type { ProjectSchema, ProjectSchemaOwners } from 'openapi';
import { useCallback, useMemo } from 'react';
import { useFlexLayout, useTable } from 'react-table';
import { formatDateYMDHMS } from 'utils/formatDate';
import { ProjectsListTableProjectName } from './ProjectsListTableProjectName.tsx';
type ProjectsListTableProps = {
projects: ProjectSchema[];
};
export const ProjectsListTable = ({ projects }: ProjectsListTableProps) => {
const { refetch } = useProjects();
const { favorite, unfavorite } = useFavoriteProjectsApi();
const onFavorite = useCallback(
async (project: ProjectSchema) => {
if (project?.favorite) {
await unfavorite(project.id);
} else {
await favorite(project.id);
}
refetch();
},
[refetch],
);
const columns = useMemo(
() => [
{
Header: '',
accessor: 'favorite',
Cell: ({ row }: { row: { original: ProjectSchema } }) => (
<FavoriteIconCell
value={row.original.favorite}
onClick={() => onFavorite(row.original)}
/>
),
width: 40,
},
{
Header: 'Project name',
accessor: 'name',
minWidth: 200,
searchable: true,
Cell: ProjectsListTableProjectName,
},
{
Header: 'Last updated',
id: 'lastUpdatedAt',
Cell: ({ row }: { row: { original: ProjectSchema } }) => (
<TimeAgoCell
value={
row.original.lastUpdatedAt || row.original.createdAt
}
dateFormat={formatDateYMDHMS}
/>
),
},
{
Header: 'Number of flags',
accessor: 'featureCount',
Cell: ({ value }: { value: number }) => (
<TextCell>
{value} flag{value === 1 ? '' : 's'}
</TextCell>
),
},
{
Header: 'Health',
accessor: 'health',
Cell: ({ value }: { value: number }) => (
<TextCell>{value}%</TextCell>
),
width: 100,
},
{
Header: 'Last seen',
accessor: 'lastReportedFlagUsage',
Cell: ({ value }: { value: Date }) => (
<ProjectLastSeen date={value} />
),
},
{
Header: 'Owner',
accessor: 'owners',
Cell: ({ value }: { value: ProjectSchemaOwners }) => (
<ProjectOwners
owners={value?.filter(
(owner) => owner.ownerType !== 'system',
)}
/>
),
},
{
Header: 'Members',
accessor: 'memberCount',
Cell: ({ value }: { value: number }) => (
<TextCell>{value} members</TextCell>
),
},
],
[onFavorite],
);
const { headerGroups, rows, prepareRow } = useTable(
{
columns: columns as any,
data: projects,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: HighlightCell,
},
},
useFlexLayout,
);
return (
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
);
};

View File

@ -0,0 +1,46 @@
import { styled } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Truncator } from 'component/common/Truncator/Truncator';
import { ProjectModeBadge } from 'component/project/ProjectCard/ProjectModeBadge/ProjectModeBadge';
import type { ProjectSchema } from 'openapi';
import { Link } from 'react-router-dom';
const StyledCellContainer = styled('div')(({ theme }) => ({
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1, 2),
}));
const StyledFeatureLink = styled(Link)({
textDecoration: 'none',
'&:hover, &:focus': {
textDecoration: 'underline',
},
});
type ProjectsListTableProjectNameProps = {
row: {
original: ProjectSchema;
};
};
export const ProjectsListTableProjectName = ({
row,
}: ProjectsListTableProjectNameProps) => {
const { searchQuery } = useSearchHighlightContext();
return (
<StyledCellContainer>
<ProjectModeBadge mode={row.original.mode} />
<StyledFeatureLink to={`/projects/${row.original.id}`}>
<Truncator title={row.original.name} lines={2} arrow>
<Highlighter search={searchQuery}>
{row.original.name}
</Highlighter>
</Truncator>
</StyledFeatureLink>
</StyledCellContainer>
);
};

View File

@ -1,4 +1,6 @@
const loadingData = [
import type { ProjectSchema } from 'openapi';
export const loadingData: ProjectSchema[] = [
{
id: 'loading1',
name: 'loading1',
@ -8,6 +10,7 @@ const loadingData = [
createdAt: '',
description: '',
mode: 'open' as const,
owners: [{ ownerType: 'user', name: 'Loading data' }],
},
{
id: 'loading2',
@ -18,6 +21,7 @@ const loadingData = [
createdAt: '',
description: '',
mode: 'open' as const,
owners: [{ ownerType: 'user', name: 'Loading data' }],
},
{
id: 'loading3',
@ -28,6 +32,7 @@ const loadingData = [
createdAt: '',
description: '',
mode: 'open' as const,
owners: [{ ownerType: 'user', name: 'Loading data' }],
},
{
id: 'loading4',
@ -38,7 +43,6 @@ const loadingData = [
createdAt: '',
description: '',
mode: 'open' as const,
owners: [{ ownerType: 'user', name: 'Loading data' }],
},
];
export default loadingData;