mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
parent
c5d6bdecac
commit
8b68a0657f
@ -8,7 +8,7 @@ import { EEA, P } from 'component/common/flags';
|
||||
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 { ProjectList } 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';
|
||||
@ -113,7 +113,7 @@ export const routes: IRoute[] = [
|
||||
{
|
||||
path: '/projects',
|
||||
title: 'Projects',
|
||||
component: ProjectListNew,
|
||||
component: ProjectList,
|
||||
type: 'protected',
|
||||
menu: { mobile: true },
|
||||
},
|
||||
|
@ -15,6 +15,8 @@ import { flexColumn } from 'themes/themeStyles';
|
||||
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||||
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
|
||||
const StyledUpdated = styled('span')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
@ -45,48 +47,58 @@ export const ProjectCard = ({
|
||||
owners,
|
||||
lastUpdatedAt,
|
||||
lastReportedFlagUsage,
|
||||
}: IProjectCard) => (
|
||||
<StyledProjectCard onMouseEnter={onHover}>
|
||||
<StyledProjectCardBody>
|
||||
<StyledDivHeader>
|
||||
<StyledIconBox>
|
||||
<ProjectIcon />
|
||||
</StyledIconBox>
|
||||
<Box
|
||||
data-loading
|
||||
sx={(theme) => ({
|
||||
...flexColumn,
|
||||
margin: theme.spacing(1, 'auto', 1, 0),
|
||||
})}
|
||||
>
|
||||
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
|
||||
{name}
|
||||
</StyledCardTitle>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(lastUpdatedAt)}
|
||||
show={
|
||||
<StyledUpdated>
|
||||
Updated <TimeAgo date={lastUpdatedAt} />
|
||||
</StyledUpdated>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<ProjectModeBadge mode={mode} />
|
||||
<FavoriteAction id={id} isFavorite={favorite} />
|
||||
</StyledDivHeader>
|
||||
<StyledInfo>
|
||||
<div>
|
||||
}: IProjectCard) => {
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
return (
|
||||
<StyledProjectCard onMouseEnter={onHover}>
|
||||
<StyledProjectCardBody>
|
||||
<StyledDivHeader>
|
||||
<StyledIconBox>
|
||||
<ProjectIcon />
|
||||
</StyledIconBox>
|
||||
<Box
|
||||
data-loading
|
||||
sx={(theme) => ({
|
||||
...flexColumn,
|
||||
margin: theme.spacing(1, 'auto', 1, 0),
|
||||
})}
|
||||
>
|
||||
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
|
||||
<Highlighter search={searchQuery}>
|
||||
{name}
|
||||
</Highlighter>
|
||||
</StyledCardTitle>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(lastUpdatedAt)}
|
||||
show={
|
||||
<StyledUpdated>
|
||||
Updated <TimeAgo date={lastUpdatedAt} />
|
||||
</StyledUpdated>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<ProjectModeBadge mode={mode} />
|
||||
<FavoriteAction id={id} isFavorite={favorite} />
|
||||
</StyledDivHeader>
|
||||
<StyledInfo>
|
||||
<div>
|
||||
<StyledCount>{featureCount}</StyledCount> flag
|
||||
{featureCount === 1 ? '' : 's'}
|
||||
<div>
|
||||
<StyledCount>{featureCount}</StyledCount> flag
|
||||
{featureCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div>
|
||||
<StyledCount>{health}%</StyledCount> health
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<StyledCount>{health}%</StyledCount> health
|
||||
</div>
|
||||
</div>
|
||||
<ProjectLastSeen date={lastReportedFlagUsage} />
|
||||
</StyledInfo>
|
||||
</StyledProjectCardBody>
|
||||
<ProjectCardFooter id={id} owners={owners} memberCount={memberCount} />
|
||||
</StyledProjectCard>
|
||||
);
|
||||
<ProjectLastSeen date={lastReportedFlagUsage} />
|
||||
</StyledInfo>
|
||||
</StyledProjectCardBody>
|
||||
<ProjectCardFooter
|
||||
id={id}
|
||||
owners={owners}
|
||||
memberCount={memberCount}
|
||||
/>
|
||||
</StyledProjectCard>
|
||||
);
|
||||
};
|
||||
|
256
frontend/src/component/project/ProjectList/LegacyProjectList.tsx
Normal file
256
frontend/src/component/project/ProjectList/LegacyProjectList.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { type FC, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
||||
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 { 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';
|
||||
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
|
||||
import { safeRegExp } from '@server/util/escape-regex';
|
||||
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
|
||||
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',
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(4),
|
||||
}));
|
||||
|
||||
type PageQueryType = Partial<Record<'search', string>>;
|
||||
|
||||
interface ICreateButtonData {
|
||||
disabled: boolean;
|
||||
tooltip?: Omit<ITooltipResolverProps, 'children'>;
|
||||
endIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
|
||||
|
||||
function resolveCreateButtonData(
|
||||
isOss: boolean,
|
||||
hasAccess: boolean,
|
||||
): ICreateButtonData {
|
||||
if (isOss) {
|
||||
return {
|
||||
disabled: true,
|
||||
tooltip: {
|
||||
titleComponent: (
|
||||
<PremiumFeature feature='adding-new-projects' tooltip />
|
||||
),
|
||||
sx: { maxWidth: '320px' },
|
||||
variant: 'custom',
|
||||
},
|
||||
endIcon: (
|
||||
<ThemeMode
|
||||
darkmode={<ProPlanIconLight />}
|
||||
lightmode={<ProPlanIcon />}
|
||||
/>
|
||||
),
|
||||
};
|
||||
} else if (!hasAccess) {
|
||||
return {
|
||||
tooltip: {
|
||||
title: 'You do not have permission to create new projects',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
tooltip: { title: 'Click to create a new project' },
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ProjectCreationButton: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const showCreateDialog = Boolean(searchParams.get('create'));
|
||||
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss, loading } = useUiConfig();
|
||||
|
||||
const createButtonData = resolveCreateButtonData(
|
||||
isOss(),
|
||||
hasAccess(CREATE_PROJECT),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveButton
|
||||
Icon={Add}
|
||||
endIcon={createButtonData.endIcon}
|
||||
onClick={() => setOpenCreateDialog(true)}
|
||||
maxWidth='700px'
|
||||
permission={CREATE_PROJECT}
|
||||
disabled={createButtonData.disabled || loading}
|
||||
tooltipProps={createButtonData.tooltip}
|
||||
data-testid={NAVIGATE_TO_CREATE_PROJECT}
|
||||
>
|
||||
New project
|
||||
</ResponsiveButton>
|
||||
<CreateProjectDialog
|
||||
open={openCreateDialog}
|
||||
onClose={() => setOpenCreateDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectList = () => {
|
||||
const { projects, loading, error, refetch } = useProjects();
|
||||
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || '',
|
||||
);
|
||||
const archiveProjectsEnabled = useUiFlag('archiveProjects');
|
||||
|
||||
const myProjects = new Set(useProfile().profile?.projects || []);
|
||||
|
||||
useEffect(() => {
|
||||
const tableState: PageQueryType = {};
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
}, [searchValue, setSearchParams]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const regExp = safeRegExp(searchValue, 'i');
|
||||
return (
|
||||
searchValue
|
||||
? projects.filter((project) => regExp.test(project.name))
|
||||
: projects
|
||||
).sort((a, b) => {
|
||||
if (a?.favorite && !b?.favorite) {
|
||||
return -1;
|
||||
}
|
||||
if (!a?.favorite && b?.favorite) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [projects, searchValue]);
|
||||
|
||||
const groupedProjects = useMemo(() => {
|
||||
return groupProjects(myProjects, filteredProjects);
|
||||
}, [filteredProjects, myProjects]);
|
||||
|
||||
const projectCount =
|
||||
filteredProjects.length < projects.length
|
||||
? `${filteredProjects.length} of ${projects.length}`
|
||||
: projects.length;
|
||||
|
||||
const ProjectGroupComponent = (props: {
|
||||
sectionTitle?: string;
|
||||
projects: IProjectCard[];
|
||||
}) => {
|
||||
return (
|
||||
<ProjectGroup
|
||||
loading={loading}
|
||||
searchValue={searchValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Projects (${projectCount})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(archiveProjectsEnabled)}
|
||||
show={
|
||||
<>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to='/projects-archive'
|
||||
>
|
||||
Archived projects
|
||||
</Link>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ProjectCreationButton />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<StyledContainer>
|
||||
<ConditionallyRender
|
||||
condition={error}
|
||||
show={() => (
|
||||
<StyledApiError
|
||||
onClick={refetch}
|
||||
text='Error fetching projects'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='My projects'
|
||||
projects={groupedProjects.myProjects}
|
||||
/>
|
||||
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='Other projects'
|
||||
projects={groupedProjects.otherProjects}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -0,0 +1,95 @@
|
||||
import { useContext, type FC, type ReactNode } from 'react';
|
||||
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
||||
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||
import Add from '@mui/icons-material/Add';
|
||||
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
|
||||
import { CreateProjectDialog } from '../../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
|
||||
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
interface ICreateButtonData {
|
||||
disabled: boolean;
|
||||
tooltip?: Omit<ITooltipResolverProps, 'children'>;
|
||||
endIcon?: ReactNode;
|
||||
}
|
||||
|
||||
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
|
||||
|
||||
function resolveCreateButtonData(
|
||||
isOss: boolean,
|
||||
hasAccess: boolean,
|
||||
): ICreateButtonData {
|
||||
if (isOss) {
|
||||
return {
|
||||
disabled: true,
|
||||
tooltip: {
|
||||
titleComponent: (
|
||||
<PremiumFeature feature='adding-new-projects' tooltip />
|
||||
),
|
||||
sx: { maxWidth: '320px' },
|
||||
variant: 'custom',
|
||||
},
|
||||
endIcon: (
|
||||
<ThemeMode
|
||||
darkmode={<ProPlanIconLight />}
|
||||
lightmode={<ProPlanIcon />}
|
||||
/>
|
||||
),
|
||||
};
|
||||
} else if (!hasAccess) {
|
||||
return {
|
||||
tooltip: {
|
||||
title: 'You do not have permission to create new projects',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
tooltip: { title: 'Click to create a new project' },
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectCreationButtonProps = {
|
||||
isDialogOpen: boolean;
|
||||
setIsDialogOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const ProjectCreationButton: FC<ProjectCreationButtonProps> = ({
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
}) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss, loading } = useUiConfig();
|
||||
|
||||
const createButtonData = resolveCreateButtonData(
|
||||
isOss(),
|
||||
hasAccess(CREATE_PROJECT),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveButton
|
||||
Icon={Add}
|
||||
endIcon={createButtonData.endIcon}
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
maxWidth='700px'
|
||||
permission={CREATE_PROJECT}
|
||||
disabled={createButtonData.disabled || loading}
|
||||
tooltipProps={createButtonData.tooltip}
|
||||
data-testid={NAVIGATE_TO_CREATE_PROJECT}
|
||||
>
|
||||
New project
|
||||
</ResponsiveButton>
|
||||
<CreateProjectDialog
|
||||
open={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -9,6 +9,7 @@ import loadingData from './loadingData';
|
||||
import { TablePlaceholder } from 'component/common/Table';
|
||||
import { styled, Typography } from '@mui/material';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
|
||||
const StyledGridContainer = styled('div')(({ theme }) => ({
|
||||
display: 'grid',
|
||||
@ -30,7 +31,10 @@ type ProjectGroupProps = {
|
||||
sectionTitle?: string;
|
||||
projects: IProjectCard[];
|
||||
loading: boolean;
|
||||
searchValue: string;
|
||||
/**
|
||||
* @deprecated remove with projectListImprovements
|
||||
*/
|
||||
searchValue?: string;
|
||||
placeholder?: string;
|
||||
ProjectCardComponent?: ComponentType<IProjectCard & any>;
|
||||
link?: boolean;
|
||||
@ -49,6 +53,7 @@ export const ProjectGroup = ({
|
||||
const ProjectCard =
|
||||
ProjectCardComponent ??
|
||||
(projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard);
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
return (
|
||||
<article>
|
||||
@ -68,11 +73,11 @@ export const ProjectGroup = ({
|
||||
condition={projects.length < 1 && !loading}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
condition={(searchValue || searchQuery)?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No projects found matching “
|
||||
{searchValue}
|
||||
{searchValue || searchQuery}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { ProjectListNew } from './ProjectList';
|
||||
import { ProjectList } from './ProjectList';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||
import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions';
|
||||
@ -21,7 +21,7 @@ const setupApi = () => {
|
||||
|
||||
test('Enabled new project button when version and permission allow for it and limit is reached', async () => {
|
||||
setupApi();
|
||||
render(<ProjectListNew />, {
|
||||
render(<ProjectList />, {
|
||||
permissions: [{ permission: CREATE_PROJECT }],
|
||||
});
|
||||
|
||||
|
@ -1,31 +1,23 @@
|
||||
import { type FC, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { type FC, useCallback } from 'react';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
||||
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 { 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';
|
||||
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
|
||||
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
|
||||
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
|
||||
import { safeRegExp } from '@server/util/escape-regex';
|
||||
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
|
||||
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';
|
||||
import { ProjectsListSort } from './ProjectsListSort/ProjectsListSort';
|
||||
import { useProjectsListState } from './hooks/useProjectsListState';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { ProjectList as LegacyProjectList } from './LegacyProjectList';
|
||||
import { ProjectCreationButton } from './ProjectCreationButton/ProjectCreationButton';
|
||||
import { useGroupedProjects } from './hooks/useGroupedProjects';
|
||||
import { useProjectsSearchAndSort } from './hooks/useProjectsSearchAndSort';
|
||||
|
||||
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
||||
maxWidth: '500px',
|
||||
@ -38,148 +30,33 @@ const StyledContainer = styled('div')(({ theme }) => ({
|
||||
gap: theme.spacing(4),
|
||||
}));
|
||||
|
||||
type PageQueryType = Partial<Record<'search', string>>;
|
||||
|
||||
interface ICreateButtonData {
|
||||
disabled: boolean;
|
||||
tooltip?: Omit<ITooltipResolverProps, 'children'>;
|
||||
endIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
|
||||
|
||||
function resolveCreateButtonData(
|
||||
isOss: boolean,
|
||||
hasAccess: boolean,
|
||||
): ICreateButtonData {
|
||||
if (isOss) {
|
||||
return {
|
||||
disabled: true,
|
||||
tooltip: {
|
||||
titleComponent: (
|
||||
<PremiumFeature feature='adding-new-projects' tooltip />
|
||||
),
|
||||
sx: { maxWidth: '320px' },
|
||||
variant: 'custom',
|
||||
},
|
||||
endIcon: (
|
||||
<ThemeMode
|
||||
darkmode={<ProPlanIconLight />}
|
||||
lightmode={<ProPlanIcon />}
|
||||
/>
|
||||
),
|
||||
};
|
||||
} else if (!hasAccess) {
|
||||
return {
|
||||
tooltip: {
|
||||
title: 'You do not have permission to create new projects',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
tooltip: { title: 'Click to create a new project' },
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ProjectCreationButton: FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const showCreateDialog = Boolean(searchParams.get('create'));
|
||||
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { isOss, loading } = useUiConfig();
|
||||
|
||||
const createButtonData = resolveCreateButtonData(
|
||||
isOss(),
|
||||
hasAccess(CREATE_PROJECT),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveButton
|
||||
Icon={Add}
|
||||
endIcon={createButtonData.endIcon}
|
||||
onClick={() => setOpenCreateDialog(true)}
|
||||
maxWidth='700px'
|
||||
permission={CREATE_PROJECT}
|
||||
disabled={createButtonData.disabled || loading}
|
||||
tooltipProps={createButtonData.tooltip}
|
||||
data-testid={NAVIGATE_TO_CREATE_PROJECT}
|
||||
>
|
||||
New project
|
||||
</ResponsiveButton>
|
||||
<CreateProjectDialog
|
||||
open={openCreateDialog}
|
||||
onClose={() => setOpenCreateDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectListNew = () => {
|
||||
const NewProjectList = () => {
|
||||
const { projects, loading, error, refetch } = useProjects();
|
||||
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || '',
|
||||
);
|
||||
|
||||
const [state, setState] = useProjectsListState();
|
||||
const archiveProjectsEnabled = useUiFlag('archiveProjects');
|
||||
|
||||
const myProjects = new Set(useProfile().profile?.projects || []);
|
||||
|
||||
useEffect(() => {
|
||||
const tableState: PageQueryType = {};
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
const setSearchValue = useCallback(
|
||||
(value: string) => setState({ query: value || undefined }),
|
||||
[setState],
|
||||
);
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
}, [searchValue, setSearchParams]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const regExp = safeRegExp(searchValue, 'i');
|
||||
return (
|
||||
searchValue
|
||||
? projects.filter((project) => regExp.test(project.name))
|
||||
: projects
|
||||
).sort((a, b) => {
|
||||
if (a?.favorite && !b?.favorite) {
|
||||
return -1;
|
||||
}
|
||||
if (!a?.favorite && b?.favorite) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [projects, searchValue]);
|
||||
|
||||
const groupedProjects = useMemo(() => {
|
||||
return groupProjects(myProjects, filteredProjects);
|
||||
}, [filteredProjects, myProjects]);
|
||||
const sortedProjects = useProjectsSearchAndSort(
|
||||
projects,
|
||||
state.query,
|
||||
state.sortBy,
|
||||
);
|
||||
const groupedProjects = useGroupedProjects(sortedProjects, myProjects);
|
||||
|
||||
const projectCount =
|
||||
filteredProjects.length < projects.length
|
||||
? `${filteredProjects.length} of ${projects.length}`
|
||||
sortedProjects.length < projects.length
|
||||
? `${sortedProjects.length} of ${projects.length}`
|
||||
: projects.length;
|
||||
|
||||
const ProjectGroupComponent = (props: {
|
||||
sectionTitle?: string;
|
||||
projects: IProjectCard[];
|
||||
}) => {
|
||||
return (
|
||||
<ProjectGroup
|
||||
loading={loading}
|
||||
searchValue={searchValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
@ -193,7 +70,7 @@ export const ProjectListNew = () => {
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
initialValue={state.query || ''}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
@ -214,8 +91,14 @@ export const ProjectListNew = () => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ProjectCreationButton />
|
||||
<ProjectCreationButton
|
||||
isDialogOpen={Boolean(state.create)}
|
||||
setIsDialogOpen={(create) =>
|
||||
setState({
|
||||
create: create ? 'true' : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@ -223,7 +106,7 @@ export const ProjectListNew = () => {
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
initialValue={state.query || ''}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
}
|
||||
@ -241,16 +124,36 @@ export const ProjectListNew = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='My projects'
|
||||
projects={groupedProjects.myProjects}
|
||||
<ProjectsListSort
|
||||
sortBy={state.sortBy}
|
||||
setSortBy={(sortBy) =>
|
||||
setState({ sortBy: sortBy as typeof state.sortBy })
|
||||
}
|
||||
/>
|
||||
<SearchHighlightProvider value={state.query || ''}>
|
||||
<ProjectGroup
|
||||
sectionTitle='My projects'
|
||||
loading={loading}
|
||||
projects={groupedProjects.myProjects}
|
||||
/>
|
||||
|
||||
<ProjectGroupComponent
|
||||
sectionTitle='Other projects'
|
||||
projects={groupedProjects.otherProjects}
|
||||
/>
|
||||
<ProjectGroup
|
||||
sectionTitle='Other projects'
|
||||
loading={loading}
|
||||
projects={groupedProjects.otherProjects}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
</StyledContainer>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectList: FC = () => {
|
||||
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
|
||||
|
||||
if (projectListImprovementsEnabled) {
|
||||
return <NewProjectList />;
|
||||
}
|
||||
|
||||
return <LegacyProjectList />;
|
||||
};
|
||||
|
@ -0,0 +1,50 @@
|
||||
import type { FC } from 'react';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
const StyledWrapper = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
margin: theme.spacing(0, 0, -4, 0),
|
||||
}));
|
||||
|
||||
const StyledContainer = styled('div')(() => ({
|
||||
maxWidth: '220px',
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
export const sortKeys = ['name', 'created', 'updated', 'seen'] as const;
|
||||
|
||||
const options: Array<{
|
||||
key: (typeof sortKeys)[number];
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: 'name', label: 'Project name' },
|
||||
{ key: 'created', label: 'Recently created' },
|
||||
{ key: 'updated', label: 'Recently updated' },
|
||||
{ key: 'seen', label: 'Last usage reported' },
|
||||
];
|
||||
|
||||
type ProjectsListSortProps = {
|
||||
sortBy: string | null | undefined;
|
||||
setSortBy: (value: string) => void;
|
||||
};
|
||||
|
||||
export const ProjectsListSort: FC<ProjectsListSortProps> = ({
|
||||
sortBy,
|
||||
setSortBy,
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<GeneralSelect
|
||||
fullWidth
|
||||
label='Sort by'
|
||||
onChange={setSortBy}
|
||||
options={options}
|
||||
value={sortBy || options[0].key}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
@ -0,0 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { groupProjects } from '../group-projects';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
|
||||
export const useGroupedProjects = (
|
||||
filteredAndSortedProjects: IProjectCard[],
|
||||
myProjects: Set<string>,
|
||||
) =>
|
||||
useMemo(
|
||||
() => groupProjects(myProjects, filteredAndSortedProjects),
|
||||
[filteredAndSortedProjects, myProjects],
|
||||
);
|
@ -0,0 +1,20 @@
|
||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
||||
import {
|
||||
createEnumParam,
|
||||
type QueryParamConfig,
|
||||
StringParam,
|
||||
withDefault,
|
||||
} from 'use-query-params';
|
||||
import { sortKeys } from '../ProjectsListSort/ProjectsListSort';
|
||||
|
||||
const stateConfig = {
|
||||
query: StringParam,
|
||||
sortBy: withDefault(
|
||||
createEnumParam([...sortKeys]),
|
||||
sortKeys[0],
|
||||
) as QueryParamConfig<(typeof sortKeys)[number] | null | undefined>,
|
||||
create: StringParam,
|
||||
} as const;
|
||||
|
||||
export const useProjectsListState = () =>
|
||||
usePersistentTableState(`projects-list`, stateConfig, ['create']);
|
@ -0,0 +1,222 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useProjectsSearchAndSort } from './useProjectsSearchAndSort';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
|
||||
const projects: IProjectCard[] = [
|
||||
{
|
||||
name: 'A - Eagle',
|
||||
id: '1',
|
||||
createdAt: '2024-01-01',
|
||||
lastUpdatedAt: '2024-01-10',
|
||||
lastReportedFlagUsage: '2024-01-15',
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
name: 'B - Horse',
|
||||
id: '2',
|
||||
createdAt: '2024-02-01',
|
||||
lastUpdatedAt: '2024-02-10',
|
||||
lastReportedFlagUsage: '2024-02-15',
|
||||
},
|
||||
{
|
||||
name: 'C - Koala',
|
||||
id: '3',
|
||||
createdAt: '2024-01-15',
|
||||
lastUpdatedAt: '2024-01-20',
|
||||
lastReportedFlagUsage: '2024-01-25',
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
name: 'D - Shark',
|
||||
id: '4',
|
||||
createdAt: '2024-03-01',
|
||||
lastUpdatedAt: '2024-03-10',
|
||||
lastReportedFlagUsage: '2024-03-15',
|
||||
favorite: true,
|
||||
},
|
||||
{
|
||||
name: 'E - Tiger',
|
||||
id: '5',
|
||||
createdAt: '2024-01-20',
|
||||
lastUpdatedAt: '2024-01-30',
|
||||
lastReportedFlagUsage: '2024-02-05',
|
||||
},
|
||||
{
|
||||
name: 'F - Zebra',
|
||||
id: '6',
|
||||
createdAt: '2024-02-15',
|
||||
lastUpdatedAt: '2024-02-20',
|
||||
lastReportedFlagUsage: '2024-02-25',
|
||||
favorite: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('useProjectsSearchAndSort', () => {
|
||||
it('should handle projects with no sorting key (default behavior)', () => {
|
||||
const { result } = renderHook(() => useProjectsSearchAndSort(projects));
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'D - Shark - favorite',
|
||||
'F - Zebra - favorite',
|
||||
'A - Eagle',
|
||||
'B - Horse',
|
||||
'C - Koala',
|
||||
'E - Tiger',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return projects sorted by creation date in descending order', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, undefined, 'created'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name} - ${project.createdAt}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'D - Shark - 2024-03-01 - favorite',
|
||||
'F - Zebra - 2024-02-15 - favorite',
|
||||
'B - Horse - 2024-02-01',
|
||||
'E - Tiger - 2024-01-20',
|
||||
'C - Koala - 2024-01-15',
|
||||
'A - Eagle - 2024-01-01',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return projects sorted by last updated date in descending order', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, undefined, 'updated'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name} - ${project.lastUpdatedAt}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'D - Shark - 2024-03-10 - favorite',
|
||||
'F - Zebra - 2024-02-20 - favorite',
|
||||
'B - Horse - 2024-02-10',
|
||||
'E - Tiger - 2024-01-30',
|
||||
'C - Koala - 2024-01-20',
|
||||
'A - Eagle - 2024-01-10',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return projects sorted by last reported flag usage in descending order', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, undefined, 'seen'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name} - ${project.lastReportedFlagUsage}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'D - Shark - 2024-03-15 - favorite',
|
||||
'F - Zebra - 2024-02-25 - favorite',
|
||||
'B - Horse - 2024-02-15',
|
||||
'E - Tiger - 2024-02-05',
|
||||
'C - Koala - 2024-01-25',
|
||||
'A - Eagle - 2024-01-15',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter projects by query and return sorted by name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, 'e', 'name'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'F - Zebra - favorite',
|
||||
'A - Eagle',
|
||||
'B - Horse',
|
||||
'E - Tiger',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle query that does not match any projects', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, 'Nonexistent'),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle query that matches some projects', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useProjectsSearchAndSort(projects, 'R'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.current.map(
|
||||
(project) =>
|
||||
`${project.name}${project.favorite ? ' - favorite' : ''}`,
|
||||
),
|
||||
).toEqual([
|
||||
'D - Shark - favorite',
|
||||
'F - Zebra - favorite',
|
||||
'B - Horse',
|
||||
'E - Tiger',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be able to deal with date', () => {
|
||||
const hook = renderHook(
|
||||
(sortBy: string) =>
|
||||
useProjectsSearchAndSort(
|
||||
[
|
||||
{
|
||||
name: 'Project A',
|
||||
id: '1',
|
||||
createdAt: '2024-01-01',
|
||||
lastUpdatedAt: '2024-03-10',
|
||||
lastReportedFlagUsage: '2024-01-15',
|
||||
},
|
||||
{
|
||||
name: 'Project B',
|
||||
id: '2',
|
||||
createdAt: new Date('2024-02-01'),
|
||||
lastUpdatedAt: new Date('2024-02-10'),
|
||||
lastReportedFlagUsage: new Date('2024-02-15'),
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
sortBy as any,
|
||||
),
|
||||
{
|
||||
initialProps: 'created',
|
||||
},
|
||||
);
|
||||
|
||||
expect(hook.result.current.map((project) => project.name)).toEqual([
|
||||
'Project B',
|
||||
'Project A',
|
||||
]);
|
||||
|
||||
hook.rerender('updated');
|
||||
expect(hook.result.current.map((project) => project.name)).toEqual([
|
||||
'Project A',
|
||||
'Project B',
|
||||
]);
|
||||
|
||||
hook.rerender('seen');
|
||||
expect(hook.result.current.map((project) => project.name)).toEqual([
|
||||
'Project B',
|
||||
'Project A',
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { safeRegExp } from '@server/util/escape-regex';
|
||||
import type { IProjectCard } from 'interfaces/project';
|
||||
import type { sortKeys } from '../ProjectsListSort/ProjectsListSort';
|
||||
|
||||
export const useProjectsSearchAndSort = (
|
||||
projects: IProjectCard[],
|
||||
query?: string | null,
|
||||
sortBy?: (typeof sortKeys)[number] | null,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
const regExp = safeRegExp(query || '', 'i');
|
||||
return (
|
||||
query
|
||||
? projects.filter((project) => regExp.test(project.name))
|
||||
: projects
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'created') {
|
||||
const aVal = new Date(a.createdAt || 0);
|
||||
const bVal = new Date(b.createdAt || 0);
|
||||
return bVal?.getTime() - aVal?.getTime();
|
||||
}
|
||||
|
||||
if (sortBy === 'updated') {
|
||||
const aVal = new Date(a.lastUpdatedAt || 0);
|
||||
const bVal = new Date(b.lastUpdatedAt || 0);
|
||||
return bVal?.getTime() - aVal?.getTime();
|
||||
}
|
||||
|
||||
if (sortBy === 'seen') {
|
||||
const aVal = new Date(a.lastReportedFlagUsage || 0);
|
||||
const bVal = new Date(b.lastReportedFlagUsage || 0);
|
||||
return bVal?.getTime() - aVal?.getTime();
|
||||
}
|
||||
|
||||
const aVal = `${a.name || ''}`.toLowerCase();
|
||||
const bVal = `${b.name || ''}`.toLowerCase();
|
||||
return aVal?.localeCompare(bVal);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a?.favorite && !b?.favorite) {
|
||||
return -1;
|
||||
}
|
||||
if (!a?.favorite && b?.favorite) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [projects, query, sortBy]);
|
@ -9,12 +9,18 @@ import { FilterItemParam } from '../utils/serializeQueryParams';
|
||||
type TestComponentProps = {
|
||||
keyName: string;
|
||||
queryParamsDefinition: Record<string, any>;
|
||||
nonPersistentParams?: string[];
|
||||
};
|
||||
|
||||
function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) {
|
||||
function TestComponent({
|
||||
keyName,
|
||||
queryParamsDefinition,
|
||||
nonPersistentParams,
|
||||
}: TestComponentProps) {
|
||||
const [tableState, setTableState] = usePersistentTableState(
|
||||
keyName,
|
||||
queryParamsDefinition,
|
||||
nonPersistentParams,
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -34,6 +34,7 @@ const usePersistentSearchParams = <T extends QueryParamConfigMap>(
|
||||
export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
||||
key: string,
|
||||
queryParamsDefinition: T,
|
||||
excludedFromStorage: string[] = ['offset'],
|
||||
) => {
|
||||
const updateStoredParams = usePersistentSearchParams(
|
||||
key,
|
||||
@ -83,8 +84,12 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { offset, ...rest } = orderedTableState;
|
||||
updateStoredParams(rest);
|
||||
const filteredTableState = Object.fromEntries(
|
||||
Object.entries(orderedTableState).filter(
|
||||
([key]) => !excludedFromStorage.includes(key),
|
||||
),
|
||||
);
|
||||
updateStoredParams(filteredTableState);
|
||||
}, [JSON.stringify(orderedTableState)]);
|
||||
|
||||
return [orderedTableState, setTableState] as const;
|
||||
|
@ -6,7 +6,7 @@ import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnte
|
||||
export interface IProjectCard {
|
||||
name: string;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
createdAt: string | Date;
|
||||
health?: number;
|
||||
description?: string;
|
||||
featureCount?: number;
|
||||
@ -15,8 +15,8 @@ export interface IProjectCard {
|
||||
onHover?: () => void;
|
||||
favorite?: boolean;
|
||||
owners?: ProjectSchema['owners'];
|
||||
lastUpdatedAt?: Date;
|
||||
lastReportedFlagUsage?: Date;
|
||||
lastUpdatedAt?: Date | string;
|
||||
lastReportedFlagUsage?: Date | string;
|
||||
}
|
||||
|
||||
export type FeatureNamingType = {
|
||||
|
Loading…
Reference in New Issue
Block a user