diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 5cd56faef..83a8a4224 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -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; export type DeleteManyPipelineProgressMutationResult = Apollo.MutationResult; export type DeleteManyPipelineProgressMutationOptions = Apollo.BaseMutationOptions; +export const DeletePipelineStageDocument = gql` + mutation DeletePipelineStage($where: PipelineStageWhereUniqueInput!) { + pipelineStage: deleteOnePipelineStage(where: $where) { + id + name + color + } +} + `; +export type DeletePipelineStageMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeletePipelineStageDocument, options); + } +export type DeletePipelineStageMutationHookResult = ReturnType; +export type DeletePipelineStageMutationResult = Apollo.MutationResult; +export type DeletePipelineStageMutationOptions = Apollo.BaseMutationOptions; export const UpdateOnePipelineProgressDocument = gql` mutation UpdateOnePipelineProgress($data: PipelineProgressUpdateInput!, $where: PipelineProgressWhereUniqueInput!) { updateOnePipelineProgress(where: $where, data: $data) { diff --git a/front/src/modules/pipeline/graphql/mutations/deletePipelineStage.ts b/front/src/modules/pipeline/graphql/mutations/deletePipelineStage.ts new file mode 100644 index 000000000..d1c017565 --- /dev/null +++ b/front/src/modules/pipeline/graphql/mutations/deletePipelineStage.ts @@ -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 + } + } +`; diff --git a/front/src/modules/pipeline/hooks/usePipelineStages.ts b/front/src/modules/pipeline/hooks/usePipelineStages.ts index 057ad00b2..db73c7717 100644 --- a/front/src/modules/pipeline/hooks/usePipelineStages.ts +++ b/front/src/modules/pipeline/hooks/usePipelineStages.ts @@ -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 }; }; diff --git a/front/src/modules/ui/board/components/BoardColumn.tsx b/front/src/modules/ui/board/components/BoardColumn.tsx index 17412fead..0e6f2cb9b 100644 --- a/front/src/modules/ui/board/components/BoardColumn.tsx +++ b/front/src/modules/ui/board/components/BoardColumn.tsx @@ -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 && ( 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 ( - {openMenu === 'actions' && ( + {currentMenu === 'actions' && ( setMenu('title')}> Rename + + + Delete + setMenu('add')}> New opportunity )} - {openMenu === 'title' && ( + {currentMenu === 'title' && ( )} - - {openMenu === 'add' && ( + {currentMenu === 'add' && ( handleCompanySelected(value)} onCancel={closeMenu} diff --git a/front/src/modules/ui/board/components/EntityBoard.tsx b/front/src/modules/ui/board/components/EntityBoard.tsx index ebd58ce3e..cddd063c8 100644 --- a/front/src/modules/ui/board/components/EntityBoard.tsx +++ b/front/src/modules/ui/board/components/EntityBoard.tsx @@ -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>, ) => 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={} availableSorts={boardOptions.sorts} onSortsUpdate={updateSorts} - onStageAdd={onStageAdd} + onStageAdd={onColumnAdd} context={CompanyBoardRecoilScopeContext} /> @@ -148,7 +150,8 @@ export function EntityBoard({ diff --git a/front/src/modules/ui/board/components/EntityBoardColumn.tsx b/front/src/modules/ui/board/components/EntityBoardColumn.tsx index a39f96540..e13b7e3a3 100644 --- a/front/src/modules/ui/board/components/EntityBoardColumn.tsx +++ b/front/src/modules/ui/board/components/EntityBoardColumn.tsx @@ -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 ( {(droppableProvided) => ( = { args: { size: 50, }, + parameters: { + chromatic: { disableSnapshot: true }, + }, }; export default meta; type Story = StoryObj; -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: { diff --git a/front/src/pages/opportunities/Opportunities.tsx b/front/src/pages/opportunities/Opportunities.tsx index e3b6b1635..8e249c400 100644 --- a/front/src/pages/opportunities/Opportunities.tsx +++ b/front/src/pages/opportunities/Opportunities.tsx @@ -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} /> diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index 06195ff8c..aa54d8f5f 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -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 }); diff --git a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts index 4f0335c6c..587d98fad 100644 --- a/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts +++ b/server/src/core/pipeline/resolvers/pipeline-stage.resolver.ts @@ -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 { + 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; + } }