feat: delete pipeline stage (#1412)

* feat: delete pipeline stage

Closes #1396

* refactor: code review

- Use string literal instead of enum

* docs: disable CircularProgressBar Chromatic snapshots
This commit is contained in:
Thaïs
2023-09-04 16:39:01 +02:00
committed by GitHub
parent 1a71f61d24
commit 96a0f30e98
11 changed files with 205 additions and 45 deletions

View File

@ -1016,6 +1016,7 @@ export type Mutation = {
deleteManyView: AffectedRows;
deleteManyViewFilter: AffectedRows;
deleteManyViewSort: AffectedRows;
deleteOnePipelineStage: PipelineStage;
deleteOneView: View;
deleteUserAccount: User;
deleteWorkspaceMember: WorkspaceMember;
@ -1186,6 +1187,11 @@ export type MutationDeleteManyViewSortArgs = {
};
export type MutationDeleteOnePipelineStageArgs = {
where: PipelineStageWhereUniqueInput;
};
export type MutationDeleteOneViewArgs = {
where: ViewWhereUniqueInput;
};
@ -3325,6 +3331,13 @@ export type DeleteManyPipelineProgressMutationVariables = Exact<{
export type DeleteManyPipelineProgressMutation = { __typename?: 'Mutation', deleteManyPipelineProgress: { __typename?: 'AffectedRows', count: number } };
export type DeletePipelineStageMutationVariables = Exact<{
where: PipelineStageWhereUniqueInput;
}>;
export type DeletePipelineStageMutation = { __typename?: 'Mutation', pipelineStage: { __typename?: 'PipelineStage', id: string, name: string, color: string } };
export type UpdateOnePipelineProgressMutationVariables = Exact<{
data: PipelineProgressUpdateInput;
where: PipelineProgressWhereUniqueInput;
@ -5554,6 +5567,41 @@ export function useDeleteManyPipelineProgressMutation(baseOptions?: Apollo.Mutat
export type DeleteManyPipelineProgressMutationHookResult = ReturnType<typeof useDeleteManyPipelineProgressMutation>;
export type DeleteManyPipelineProgressMutationResult = Apollo.MutationResult<DeleteManyPipelineProgressMutation>;
export type DeleteManyPipelineProgressMutationOptions = Apollo.BaseMutationOptions<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>;
export const DeletePipelineStageDocument = gql`
mutation DeletePipelineStage($where: PipelineStageWhereUniqueInput!) {
pipelineStage: deleteOnePipelineStage(where: $where) {
id
name
color
}
}
`;
export type DeletePipelineStageMutationFn = Apollo.MutationFunction<DeletePipelineStageMutation, DeletePipelineStageMutationVariables>;
/**
* __useDeletePipelineStageMutation__
*
* To run a mutation, you first call `useDeletePipelineStageMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeletePipelineStageMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deletePipelineStageMutation, { data, loading, error }] = useDeletePipelineStageMutation({
* variables: {
* where: // value for 'where'
* },
* });
*/
export function useDeletePipelineStageMutation(baseOptions?: Apollo.MutationHookOptions<DeletePipelineStageMutation, DeletePipelineStageMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeletePipelineStageMutation, DeletePipelineStageMutationVariables>(DeletePipelineStageDocument, options);
}
export type DeletePipelineStageMutationHookResult = ReturnType<typeof useDeletePipelineStageMutation>;
export type DeletePipelineStageMutationResult = Apollo.MutationResult<DeletePipelineStageMutation>;
export type DeletePipelineStageMutationOptions = Apollo.BaseMutationOptions<DeletePipelineStageMutation, DeletePipelineStageMutationVariables>;
export const UpdateOnePipelineProgressDocument = gql`
mutation UpdateOnePipelineProgress($data: PipelineProgressUpdateInput!, $where: PipelineProgressWhereUniqueInput!) {
updateOnePipelineProgress(where: $where, data: $data) {

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const DELETE_PIPELINE_STAGE = gql`
mutation DeletePipelineStage($where: PipelineStageWhereUniqueInput!) {
pipelineStage: deleteOnePipelineStage(where: $where) {
id
name
color
}
}
`;

View File

@ -2,7 +2,10 @@ import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue } from 'recoil';
import type { BoardColumnDefinition } from '@/ui/board/types/BoardColumnDefinition';
import { useCreatePipelineStageMutation } from '~/generated/graphql';
import {
useCreatePipelineStageMutation,
useDeletePipelineStageMutation,
} from '~/generated/graphql';
import { GET_PIPELINES } from '../graphql/queries/getPipelines';
import { currentPipelineState } from '../states/currentPipelineState';
@ -11,6 +14,7 @@ export const usePipelineStages = () => {
const currentPipeline = useRecoilValue(currentPipelineState);
const [createPipelineStageMutation] = useCreatePipelineStageMutation();
const [deletePipelineStageMutation] = useDeletePipelineStageMutation();
const handlePipelineStageAdd = async (boardColumn: BoardColumnDefinition) => {
if (!currentPipeline?.id) return;
@ -30,5 +34,14 @@ export const usePipelineStages = () => {
});
};
return { handlePipelineStageAdd };
const handlePipelineStageDelete = async (boardColumnId: string) => {
if (!currentPipeline?.id) return;
return deletePipelineStageMutation({
variables: { where: { id: boardColumnId } },
refetchQueries: [getOperationName(GET_PIPELINES) ?? ''],
});
};
return { handlePipelineStageAdd, handlePipelineStageDelete };
};

View File

@ -54,6 +54,7 @@ const StyledNumChildren = styled.div`
export type BoardColumnProps = {
color: string;
title: string;
onDelete?: (id: string) => void;
onTitleEdit: (title: string, color: string) => void;
totalAmount?: number;
children: React.ReactNode;
@ -65,6 +66,7 @@ export type BoardColumnProps = {
export function BoardColumn({
color,
title,
onDelete,
onTitleEdit,
totalAmount,
children,
@ -102,6 +104,7 @@ export function BoardColumn({
{isBoardColumnMenuOpen && (
<BoardColumnMenu
onClose={handleClose}
onDelete={onDelete}
onTitleEdit={onTitleEdit}
title={title}
color={color}

View File

@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useCreateCompanyProgress } from '@/companies/hooks/useCreateCompanyProgress';
@ -7,7 +8,7 @@ import { useFilteredSearchCompanyQuery } from '@/companies/hooks/useFilteredSear
import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem';
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
import { IconPencil, IconPlus } from '@/ui/icon';
import { IconPencil, IconPlus, IconTrash } from '@/ui/icon';
import { SingleEntitySelect } from '@/ui/input/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/input/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
@ -19,6 +20,7 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { boardColumnsState } from '../states/boardColumnsState';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu';
@ -30,22 +32,30 @@ const StyledMenuContainer = styled.div`
`;
type OwnProps = {
onClose: () => void;
title: string;
color: string;
onClose: () => void;
onDelete?: (id: string) => void;
onTitleEdit: (title: string, color: string) => void;
stageId: string;
title: string;
};
type Menu = 'actions' | 'add' | 'title';
export function BoardColumnMenu({
onClose,
onTitleEdit,
title,
color,
onClose,
onDelete,
onTitleEdit,
stageId,
title,
}: OwnProps) {
const [openMenu, setOpenMenu] = useState('actions');
const [currentMenu, setCurrentMenu] = useState('actions');
const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState);
const boardColumnMenuRef = useRef(null);
const { enqueueSnackBar } = useSnackBar();
const createCompanyProgress = useCreateCompanyProgress();
@ -70,23 +80,31 @@ export function BoardColumnMenu({
closeMenu();
}
function closeMenu() {
goBackToPreviousHotkeyScope();
onClose();
}
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
function setMenu(menu: string) {
const closeMenu = useCallback(() => {
goBackToPreviousHotkeyScope();
onClose();
}, [goBackToPreviousHotkeyScope, onClose]);
const handleDelete = useCallback(() => {
setBoardColumns((previousBoardColumns) =>
previousBoardColumns.filter((column) => column.id !== stageId),
);
onDelete?.(stageId);
closeMenu();
}, [closeMenu, onDelete, setBoardColumns, stageId]);
function setMenu(menu: Menu) {
if (menu === 'add') {
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}
setOpenMenu(menu);
setCurrentMenu(menu);
}
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
@ -108,19 +126,26 @@ export function BoardColumnMenu({
return (
<StyledMenuContainer ref={boardColumnMenuRef}>
<StyledDropdownMenu>
{openMenu === 'actions' && (
{currentMenu === 'actions' && (
<StyledDropdownMenuItemsContainer>
<DropdownMenuSelectableItem onClick={() => setMenu('title')}>
<IconPencil size={icon.size.md} stroke={icon.stroke.sm} />
Rename
</DropdownMenuSelectableItem>
<DropdownMenuSelectableItem
disabled={boardColumns.length <= 1}
onClick={handleDelete}
>
<IconTrash size={icon.size.md} stroke={icon.stroke.sm} />
Delete
</DropdownMenuSelectableItem>
<DropdownMenuSelectableItem onClick={() => setMenu('add')}>
<IconPlus size={icon.size.md} stroke={icon.stroke.sm} />
New opportunity
</DropdownMenuSelectableItem>
</StyledDropdownMenuItemsContainer>
)}
{openMenu === 'title' && (
{currentMenu === 'title' && (
<BoardColumnEditTitleMenu
color={color}
onClose={closeMenu}
@ -128,8 +153,7 @@ export function BoardColumnMenu({
title={title}
/>
)}
{openMenu === 'add' && (
{currentMenu === 'add' && (
<SingleEntitySelect
onEntitySelected={(value) => handleCompanySelected(value)}
onCancel={closeMenu}

View File

@ -43,16 +43,18 @@ const StyledWrapper = styled.div`
export function EntityBoard({
boardOptions,
updateSorts,
onColumnAdd,
onColumnDelete,
onEditColumnTitle,
onStageAdd,
updateSorts,
}: {
boardOptions: BoardOptions;
onColumnAdd?: (boardColumn: BoardColumnDefinition) => void;
onColumnDelete?: (boardColumnId: string) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
updateSorts: (
sorts: Array<SelectedSortType<PipelineProgressOrderByWithRelationInput>>,
) => void;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
onStageAdd?: (boardColumn: BoardColumnDefinition) => void;
}) {
const [boardColumns] = useRecoilState(boardColumnsState);
const setCardSelected = useSetCardSelected();
@ -133,7 +135,7 @@ export function EntityBoard({
viewIcon={<IconList size={theme.icon.size.md} />}
availableSorts={boardOptions.sorts}
onSortsUpdate={updateSorts}
onStageAdd={onStageAdd}
onStageAdd={onColumnAdd}
context={CompanyBoardRecoilScopeContext}
/>
<ScrollWrapper>
@ -148,7 +150,8 @@ export function EntityBoard({
<EntityBoardColumn
boardOptions={boardOptions}
column={column}
onEditColumnTitle={onEditColumnTitle}
onTitleEdit={onEditColumnTitle}
onDelete={onColumnDelete}
/>
</RecoilScope>
</BoardColumnIdContext.Provider>

View File

@ -50,11 +50,13 @@ const BoardColumnCardsContainer = ({
export function EntityBoardColumn({
column,
boardOptions,
onEditColumnTitle,
onDelete,
onTitleEdit,
}: {
column: BoardColumnDefinition;
boardOptions: BoardOptions;
onEditColumnTitle: (columnId: string, title: string, color: string) => void;
onDelete?: (columnId: string) => void;
onTitleEdit: (columnId: string, title: string, color: string) => void;
}) {
const boardColumnId = useContext(BoardColumnIdContext) ?? '';
@ -66,15 +68,16 @@ export function EntityBoardColumn({
boardCardIdsByColumnIdFamilyState(boardColumnId ?? ''),
);
function handleEditColumnTitle(title: string, color: string) {
onEditColumnTitle(boardColumnId, title, color);
function handleTitleEdit(title: string, color: string) {
onTitleEdit(boardColumnId, title, color);
}
return (
<Droppable droppableId={column.id}>
{(droppableProvided) => (
<BoardColumn
onTitleEdit={handleEditColumnTitle}
onTitleEdit={handleTitleEdit}
onDelete={onDelete}
title={column.title}
color={column.colorCode}
totalAmount={boardColumnTotal}

View File

@ -11,27 +11,23 @@ const meta: Meta<typeof CircularProgressBar> = {
args: {
size: 50,
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof CircularProgressBar>;
const args = {};
const defaultArgTypes = {
control: false,
};
export const Default: Story = {
args,
decorators: [ComponentDecorator],
};
export const Catalog = {
args: {
...args,
},
argTypes: {
strokeWidth: defaultArgTypes,
segmentColor: defaultArgTypes,
strokeWidth: { control: false },
segmentColor: { control: false },
},
parameters: {
catalog: {

View File

@ -44,7 +44,8 @@ export function Opportunities() {
[],
);
const { handlePipelineStageAdd } = usePipelineStages();
const { handlePipelineStageAdd, handlePipelineStageDelete } =
usePipelineStages();
const [updatePipelineStage] = useUpdatePipelineStageMutation();
@ -89,7 +90,8 @@ export function Opportunities() {
boardOptions={opportunitiesBoardOptions}
updateSorts={updateSorts}
onEditColumnTitle={handleEditColumnTitle}
onStageAdd={handlePipelineStageAdd}
onColumnAdd={handlePipelineStageAdd}
onColumnDelete={handlePipelineStageDelete}
/>
<EntityBoardActionBar />
<EntityBoardContextMenu />

View File

@ -128,6 +128,7 @@ export class AbilityFactory {
can(AbilityAction.Read, 'PipelineStage', { workspaceId: workspace.id });
can(AbilityAction.Create, 'PipelineStage', { workspaceId: workspace.id });
can(AbilityAction.Update, 'PipelineStage', { workspaceId: workspace.id });
can(AbilityAction.Delete, 'PipelineStage', { workspaceId: workspace.id });
// PipelineProgress
can(AbilityAction.Read, 'PipelineProgress', { workspaceId: workspace.id });

View File

@ -1,5 +1,9 @@
import { Resolver, Args, Query, Mutation } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import {
ForbiddenException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { accessibleBy } from '@casl/prisma';
import { Prisma, Workspace } from '@prisma/client';
@ -12,6 +16,7 @@ import { AbilityGuard } from 'src/guards/ability.guard';
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import {
CreatePipelineStageAbilityHandler,
DeletePipelineStageAbilityHandler,
ReadPipelineStageAbilityHandler,
UpdatePipelineStageAbilityHandler,
} from 'src/ability/handlers/pipeline-stage.ability-handler';
@ -24,6 +29,7 @@ import {
import { UpdateOnePipelineStageArgs } from 'src/core/@generated/pipeline-stage/update-one-pipeline-stage.args';
import { CreateOnePipelineStageArgs } from 'src/core/@generated/pipeline-stage/create-one-pipeline-stage.args';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { DeleteOnePipelineStageArgs } from 'src/core/@generated/pipeline-stage/delete-one-pipeline-stage.args';
@UseGuards(JwtAuthGuard)
@Resolver(() => PipelineStage)
@ -90,4 +96,54 @@ export class PipelineStageResolver {
select: prismaSelect.value,
} as Prisma.PipelineProgressUpdateArgs);
}
@Mutation(() => PipelineStage, {
nullable: false,
})
@UseGuards(AbilityGuard)
@CheckAbilities(DeletePipelineStageAbilityHandler)
async deleteOnePipelineStage(
@Args() args: DeleteOnePipelineStageArgs,
): Promise<PipelineStage> {
const pipelineStageToDelete = await this.pipelineStageService.findUnique({
where: args.where,
});
if (!pipelineStageToDelete) {
throw new NotFoundException();
}
const { pipelineId } = pipelineStageToDelete;
const remainingPipelineStages = await this.pipelineStageService.findMany({
orderBy: { index: 'asc' },
where: {
pipelineId,
NOT: { id: pipelineStageToDelete.id },
},
});
if (!remainingPipelineStages.length) {
throw new ForbiddenException(
`Deleting last pipeline stage is not allowed`,
);
}
const deletedPipelineStage = await this.pipelineStageService.delete({
where: args.where,
});
await Promise.all(
remainingPipelineStages.map((pipelineStage, index) => {
if (pipelineStage.index === index) return;
return this.pipelineStageService.update({
data: { index },
where: { id: pipelineStage.id },
});
}),
);
return deletedPipelineStage;
}
}