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

feat: dialogs for project revive and delete (#7863)

Dialog needed to confirm revive/delete actions
This commit is contained in:
Tymoteusz Czech 2024-08-15 09:25:49 +02:00 committed by GitHub
parent 627768b96c
commit 3baeb4c541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 200 additions and 102 deletions

View File

@ -22,59 +22,35 @@ import TimeAgo from 'react-timeago';
import { Box, Link, Tooltip } from '@mui/material'; import { Box, Link, Tooltip } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { import {
CREATE_PROJECT,
DELETE_PROJECT, DELETE_PROJECT,
UPDATE_PROJECT,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import Undo from '@mui/icons-material/Undo'; import Undo from '@mui/icons-material/Undo';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
interface IProjectArchiveCardProps { export type ProjectArchiveCardProps = {
id: string; id: string;
name: string; name: string;
createdAt?: string;
archivedAt?: string; archivedAt?: string;
featureCount: number; archivedFeaturesCount?: number;
onRevive: () => void; onRevive: () => void;
onDelete: () => void; onDelete: () => void;
mode: string; mode?: string;
owners?: ProjectSchemaOwners; owners?: ProjectSchemaOwners;
} };
export const ProjectArchiveCard: FC<IProjectArchiveCardProps> = ({ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
id, id,
name, name,
archivedAt, archivedAt,
featureCount = 0, archivedFeaturesCount,
onRevive, onRevive,
onDelete, onDelete,
mode, mode,
owners, owners,
}) => { }) => {
const { locationSettings } = useLocationSettings(); 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 ( return (
<StyledProjectCard disabled> <StyledProjectCard disabled>
@ -89,17 +65,6 @@ export const ProjectArchiveCard: FC<IProjectArchiveCardProps> = ({
<ProjectModeBadge mode={mode} /> <ProjectModeBadge mode={mode} />
</StyledDivHeader> </StyledDivHeader>
<StyledDivInfo> <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 <ConditionallyRender
condition={Boolean(archivedAt)} condition={Boolean(archivedAt)}
show={ show={
@ -129,15 +94,47 @@ export const ProjectArchiveCard: FC<IProjectArchiveCardProps> = ({
</Tooltip> </Tooltip>
} }
/> />
<ConditionallyRender
condition={typeof archivedFeaturesCount !== 'undefined'}
show={
<Link
component={RouterLink}
to={`/archive?search=project%3A${encodeURI(id)}`}
>
<StyledParagraphInfo disabled data-loading>
{archivedFeaturesCount}
</StyledParagraphInfo>
<p data-loading>
archived{' '}
{archivedFeaturesCount === 1
? 'flag'
: 'flags'}
</p>
</Link>
}
/>
</StyledDivInfo> </StyledDivInfo>
</StyledProjectCardBody> </StyledProjectCardBody>
<ProjectCardFooter <ProjectCardFooter id={id} disabled owners={owners}>
id={id} <StyledActions>
Actions={Actions} <PermissionIconButton
disabled onClick={onRevive}
owners={owners} projectId={id}
> permission={UPDATE_PROJECT}
<Actions id={id} /> 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>
</ProjectCardFooter> </ProjectCardFooter>
</StyledProjectCard> </StyledProjectCard>
); );

View File

@ -10,7 +10,6 @@ interface IProjectCardFooterProps {
id: string; id: string;
isFavorite?: boolean; isFavorite?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
Actions?: FC<{ id: string; isFavorite?: boolean }>;
disabled?: boolean; disabled?: boolean;
owners: IProjectOwnersProps['owners']; owners: IProjectOwnersProps['owners'];
} }

View File

@ -5,7 +5,7 @@ import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
interface IProjectModeBadgeProps { interface IProjectModeBadgeProps {
mode: 'private' | 'protected' | 'public' | string; mode?: 'private' | 'protected' | 'public' | string;
} }
export const ProjectModeBadge: FC<IProjectModeBadgeProps> = ({ mode }) => { export const ProjectModeBadge: FC<IProjectModeBadgeProps> = ({ mode }) => {

View File

@ -23,7 +23,8 @@ export const DeleteProjectDialogue = ({
onSuccess, onSuccess,
}: IDeleteProjectDialogueProps) => { }: IDeleteProjectDialogueProps) => {
const { deleteProject } = useProjectApi(); const { deleteProject } = useProjectApi();
const { refetch: refetchProjectOverview } = useProjects(); const { refetch: refetchProjects } = useProjects();
const { refetch: refetchProjectArchive } = useProjects({ archived: true });
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { isEnterprise } = useUiConfig(); const { isEnterprise } = useUiConfig();
const automatedActionsEnabled = useUiFlag('automatedActions'); const automatedActionsEnabled = useUiFlag('automatedActions');
@ -32,7 +33,8 @@ export const DeleteProjectDialogue = ({
e.preventDefault(); e.preventDefault();
try { try {
await deleteProject(project); await deleteProject(project);
refetchProjectOverview(); refetchProjects();
refetchProjectArchive();
setToastData({ setToastData({
title: 'Deleted project', title: 'Deleted project',
type: 'success', type: 'success',

View File

@ -34,7 +34,6 @@ export const ArchiveProject = ({
}: IDeleteProjectProps) => { }: IDeleteProjectProps) => {
const { isEnterprise } = useUiConfig(); const { isEnterprise } = useUiConfig();
const automatedActionsEnabled = useUiFlag('automatedActions'); const automatedActionsEnabled = useUiFlag('automatedActions');
const archiveProjectsEnabled = useUiFlag('archiveProjects');
const { actions } = useActions(projectId); const { actions } = useActions(projectId);
const [showArchiveDialog, setShowArchiveDialog] = useState(false); const [showArchiveDialog, setShowArchiveDialog] = useState(false);
const actionsCount = actions.filter(({ enabled }) => enabled).length; const actionsCount = actions.filter(({ enabled }) => enabled).length;

View File

@ -1,6 +1,5 @@
import { type FC, useEffect, useState } from 'react'; import { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import useProjectsArchive from 'hooks/api/getters/useProjectsArchive/useProjectsArchive';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -9,7 +8,13 @@ import { styled, useMediaQuery } from '@mui/material';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search'; import { Search } from 'component/common/Search/Search';
import { ProjectGroup } from './ProjectGroup'; import { ProjectGroup } from './ProjectGroup';
import { ProjectArchiveCard } from '../NewProjectCard/ProjectArchiveCard'; import {
ProjectArchiveCard,
type ProjectArchiveCardProps,
} from '../NewProjectCard/ProjectArchiveCard';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { ReviveProjectDialog } from './ReviveProjectDialog/ReviveProjectDialog';
import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue';
const StyledApiError = styled(ApiError)(({ theme }) => ({ const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px', maxWidth: '500px',
@ -25,13 +30,24 @@ const StyledContainer = styled('div')(({ theme }) => ({
type PageQueryType = Partial<Record<'search', string>>; type PageQueryType = Partial<Record<'search', string>>;
export const ArchiveProjectList: FC = () => { export const ArchiveProjectList: FC = () => {
const { projects, loading, error, refetch } = useProjectsArchive(); const { projects, loading, error, refetch } = useProjects({
archived: true,
});
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState( const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '', searchParams.get('search') || '',
); );
const [reviveProject, setReviveProject] = useState<{
isOpen: boolean;
id?: string;
name?: string;
}>({ isOpen: false });
const [deleteProject, setDeleteProject] = useState<{
isOpen: boolean;
id?: string;
}>({ isOpen: false });
useEffect(() => { useEffect(() => {
const tableState: PageQueryType = {}; const tableState: PageQueryType = {};
@ -44,6 +60,28 @@ export const ArchiveProjectList: FC = () => {
}); });
}, [searchValue, setSearchParams]); }, [searchValue, setSearchParams]);
const ProjectCard: FC<
Omit<ProjectArchiveCardProps, 'onRevive' | 'onDelete'>
> = ({ id, ...props }) => (
<ProjectArchiveCard
onRevive={() =>
setReviveProject({
isOpen: true,
id,
name: projects?.find((project) => project.id === id)?.name,
})
}
onDelete={() =>
setDeleteProject({
id,
isOpen: true,
})
}
id={id}
{...props}
/>
);
return ( return (
<PageContent <PageContent
isLoading={loading} isLoading={loading}
@ -90,10 +128,25 @@ export const ArchiveProjectList: FC = () => {
searchValue={searchValue} searchValue={searchValue}
projects={projects} projects={projects}
placeholder='No archived projects found' placeholder='No archived projects found'
ProjectCardComponent={ProjectArchiveCard} ProjectCardComponent={ProjectCard}
link={false} link={false}
/> />
</StyledContainer> </StyledContainer>
<ReviveProjectDialog
id={reviveProject.id || ''}
name={reviveProject.name || ''}
open={reviveProject.isOpen}
onClose={() =>
setReviveProject((state) => ({ ...state, isOpen: false }))
}
/>
<DeleteProjectDialogue
project={deleteProject.id || ''}
open={deleteProject.isOpen}
onClose={() => {
setDeleteProject((state) => ({ ...state, isOpen: false }));
}}
/>
</PageContent> </PageContent>
); );
}; };

View File

@ -0,0 +1,56 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
type ReviveProjectDialogProps = {
name: string;
id: string;
open: boolean;
onClose: () => void;
};
export const ReviveProjectDialog = ({
name,
id,
open,
onClose,
}: ReviveProjectDialogProps) => {
const { reviveProject } = useProjectApi();
const { refetch: refetchProjects } = useProjects();
const { refetch: refetchProjectArchive } = useProjects({ archived: true });
const { setToastData, setToastApiError } = useToast();
const onClick = async (e: React.SyntheticEvent) => {
e.preventDefault();
if (!id) return;
try {
await reviveProject(id);
refetchProjects();
refetchProjectArchive();
setToastData({
title: 'Restored project',
type: 'success',
text: 'Successfully restored project',
});
} catch (ex: unknown) {
setToastApiError(formatUnknownError(ex));
}
onClose();
};
return (
<Dialogue
open={open}
secondaryButtonText='Close'
onClose={onClose}
onClick={onClick}
title='Restore archived project'
>
Are you sure you'd like to restore project <strong>{name}</strong>{' '}
(id: <code>{id}</code>)?
{/* TODO: more explanation */}
</Dialogue>
);
};

View File

@ -81,7 +81,16 @@ const useProjectApi = () => {
}; };
const archiveProject = async (projectId: string) => { const archiveProject = async (projectId: string) => {
const path = `api/admin/projects/${projectId}/archive`; const path = `api/admin/projects/archive/${projectId}`;
const req = createRequest(path, { method: 'POST' });
const res = await makeRequest(req.caller, req.id);
return res;
};
const reviveProject = async (projectId: string) => {
const path = `api/admin/projects/revive/${projectId}`;
const req = createRequest(path, { method: 'POST' }); const req = createRequest(path, { method: 'POST' });
const res = await makeRequest(req.caller, req.id); const res = await makeRequest(req.caller, req.id);
@ -263,6 +272,7 @@ const useProjectApi = () => {
editProjectSettings, editProjectSettings,
deleteProject, deleteProject,
archiveProject, archiveProject,
reviveProject,
addEnvironmentToProject, addEnvironmentToProject,
removeEnvironmentFromProject, removeEnvironmentFromProject,
addAccessToProject, addAccessToProject,

View File

@ -4,10 +4,13 @@ import { formatApiPath } from 'utils/formatPath';
import type { IProjectCard } from 'interfaces/project'; import type { IProjectCard } from 'interfaces/project';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import type { GetProjectsParams } from 'openapi';
const useProjects = (options: SWRConfiguration & GetProjectsParams = {}) => {
const KEY = `api/admin/projects${options.archived ? '?archived=true' : ''}`;
const useProjects = (options: SWRConfiguration = {}) => {
const fetcher = () => { const fetcher = () => {
const path = formatApiPath(`api/admin/projects`); const path = formatApiPath(KEY);
return fetch(path, { return fetch(path, {
method: 'GET', method: 'GET',
}) })
@ -15,8 +18,6 @@ const useProjects = (options: SWRConfiguration = {}) => {
.then((res) => res.json()); .then((res) => res.json());
}; };
const KEY = `api/admin/projects`;
const { data, error } = useSWR<{ projects: IProjectCard[] }>( const { data, error } = useSWR<{ projects: IProjectCard[] }>(
KEY, KEY,
fetcher, fetcher,

View File

@ -1,39 +0,0 @@
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

@ -0,0 +1,9 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetProjectsParams = {
archived?: boolean;
};

View File

@ -722,6 +722,7 @@ export * from './getProjectUsers401';
export * from './getProjectUsers403'; export * from './getProjectUsers403';
export * from './getProjects401'; export * from './getProjects401';
export * from './getProjects403'; export * from './getProjects403';
export * from './getProjectsParams';
export * from './getPublicSignupToken401'; export * from './getPublicSignupToken401';
export * from './getPublicSignupToken403'; export * from './getPublicSignupToken403';
export * from './getRawFeatureMetrics401'; export * from './getRawFeatureMetrics401';

View File

@ -77,6 +77,16 @@ export default class ProjectController extends Controller {
summary: 'Get a list of all projects.', summary: 'Get a list of all projects.',
description: description:
'This endpoint returns an list of all the projects in the Unleash instance.', 'This endpoint returns an list of all the projects in the Unleash instance.',
parameters: [
{
name: 'archived',
in: 'query',
required: false,
schema: {
type: 'boolean',
},
},
],
responses: { responses: {
200: createResponseSchema('projectsSchema'), 200: createResponseSchema('projectsSchema'),
...getStandardResponses(401, 403), ...getStandardResponses(401, 403),