mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +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:
parent
837c49e4a1
commit
44650e4e2f
@ -25,7 +25,8 @@ const StyledWrapper = styled(Box, {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledSpan = styled('span')(() => ({
|
const StyledSpan = styled('span')(() => ({
|
||||||
display: 'inline-block',
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import type { ComponentType, ReactNode } from 'react';
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { ProjectCard as DefaultProjectCard } from '../ProjectCard/ProjectCard.tsx';
|
import { ProjectCard as DefaultProjectCard } from '../ProjectCard/ProjectCard.tsx';
|
||||||
import type { ProjectSchema } from 'openapi';
|
import type { ProjectSchema } from 'openapi';
|
||||||
import loadingData from './loadingData.ts';
|
import { loadingData } from './loadingData.ts';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { UpgradeProjectCard } from '../ProjectCard/UpgradeProjectCard.tsx';
|
import { UpgradeProjectCard } from '../ProjectCard/UpgradeProjectCard.tsx';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
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 }) => ({
|
const StyledGridContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -33,6 +34,7 @@ type ProjectGroupProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
ProjectCardComponent?: ComponentType<ProjectSchema & any>;
|
ProjectCardComponent?: ComponentType<ProjectSchema & any>;
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
|
view?: ProjectsListView;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectGroup = ({
|
export const ProjectGroup = ({
|
||||||
@ -40,54 +42,31 @@ export const ProjectGroup = ({
|
|||||||
loading,
|
loading,
|
||||||
ProjectCardComponent,
|
ProjectCardComponent,
|
||||||
link = true,
|
link = true,
|
||||||
|
view = 'cards',
|
||||||
}: ProjectGroupProps) => {
|
}: ProjectGroupProps) => {
|
||||||
const ProjectCard = ProjectCardComponent ?? DefaultProjectCard;
|
const ProjectCard = ProjectCardComponent ?? DefaultProjectCard;
|
||||||
const { isOss } = useUiConfig();
|
const { isOss } = useUiConfig();
|
||||||
|
|
||||||
|
const projectsToRender = loading ? loadingData : projects;
|
||||||
|
|
||||||
|
if (!isOss() && view === 'list') {
|
||||||
|
return <ProjectsListTable projects={projectsToRender} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledGridContainer>
|
<StyledGridContainer>
|
||||||
<ConditionallyRender
|
{projectsToRender.map((project) =>
|
||||||
condition={loading}
|
link ? (
|
||||||
show={() => (
|
<StyledCardLink
|
||||||
<>
|
key={project.id}
|
||||||
{loadingData.map((project: ProjectSchema) => (
|
to={`/projects/${project.id}`}
|
||||||
<ProjectCard
|
>
|
||||||
data-loading
|
<ProjectCard data-loading {...project} />
|
||||||
createdAt={project.createdAt}
|
</StyledCardLink>
|
||||||
key={project.id}
|
) : (
|
||||||
name={project.name}
|
<ProjectCard data-loading key={project.id} {...project} />
|
||||||
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} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isOss() ? <UpgradeProjectCard /> : null}
|
{isOss() ? <UpgradeProjectCard /> : null}
|
||||||
</StyledGridContainer>
|
</StyledGridContainer>
|
||||||
);
|
);
|
||||||
|
@ -30,7 +30,7 @@ const StyledApiError = styled(ApiError)(({ theme }) => ({
|
|||||||
const StyledContainer = styled('div')(({ theme }) => ({
|
const StyledContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: theme.spacing(6),
|
gap: theme.spacing(4),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const ProjectList = () => {
|
export const ProjectList = () => {
|
||||||
@ -154,6 +154,7 @@ export const ProjectList = () => {
|
|||||||
</ProjectsListHeader>
|
</ProjectsListHeader>
|
||||||
<ProjectGroup
|
<ProjectGroup
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
view={state.view}
|
||||||
projects={
|
projects={
|
||||||
isOss()
|
isOss()
|
||||||
? sortedProjects
|
? sortedProjects
|
||||||
@ -169,6 +170,7 @@ export const ProjectList = () => {
|
|||||||
</ProjectsListHeader>
|
</ProjectsListHeader>
|
||||||
<ProjectGroup
|
<ProjectGroup
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
view={state.view}
|
||||||
projects={otherProjects}
|
projects={otherProjects}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,6 +11,7 @@ type ProjectsListHeaderProps = {
|
|||||||
const StyledHeaderContainer = styled('div')(({ theme }) => ({
|
const StyledHeaderContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column-reverse',
|
flexDirection: 'column-reverse',
|
||||||
|
minHeight: theme.spacing(5),
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
[theme.breakpoints.up('md')]: {
|
[theme.breakpoints.up('md')]: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,6 @@
|
|||||||
const loadingData = [
|
import type { ProjectSchema } from 'openapi';
|
||||||
|
|
||||||
|
export const loadingData: ProjectSchema[] = [
|
||||||
{
|
{
|
||||||
id: 'loading1',
|
id: 'loading1',
|
||||||
name: 'loading1',
|
name: 'loading1',
|
||||||
@ -8,6 +10,7 @@ const loadingData = [
|
|||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
|
owners: [{ ownerType: 'user', name: 'Loading data' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading2',
|
id: 'loading2',
|
||||||
@ -18,6 +21,7 @@ const loadingData = [
|
|||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
|
owners: [{ ownerType: 'user', name: 'Loading data' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading3',
|
id: 'loading3',
|
||||||
@ -28,6 +32,7 @@ const loadingData = [
|
|||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
|
owners: [{ ownerType: 'user', name: 'Loading data' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading4',
|
id: 'loading4',
|
||||||
@ -38,7 +43,6 @@ const loadingData = [
|
|||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
|
owners: [{ ownerType: 'user', name: 'Loading data' }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default loadingData;
|
|
||||||
|
Loading…
Reference in New Issue
Block a user