From a24e1e4dc9b81ede3086f28499a9a56c77052d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Wed, 16 Aug 2023 23:27:03 +0200 Subject: [PATCH] feat: delete views from views dropdown (#1234) Closes #1129 Co-authored-by: Charles Bochet --- front/src/generated/graphql.tsx | 50 +++++++++++++++++++ .../table/components/CompanyTable.tsx | 5 +- .../people/table/components/PeopleTable.tsx | 4 +- .../ui/table/components/EntityTableHeader.tsx | 6 +-- .../components/TableOptionsDropdownButton.tsx | 3 +- .../components/TableViewsDropdownButton.tsx | 40 +++++++++++++-- .../table-header/components/TableHeader.tsx | 1 + .../views/graphql/mutations/deleteView.ts | 9 ++++ .../modules/views/hooks/useTableViewFields.ts | 4 +- .../src/modules/views/hooks/useTableViews.ts | 28 ++++++++++- front/src/modules/views/hooks/useViewSorts.ts | 7 ++- server/src/ability/ability.factory.ts | 6 +-- server/src/ability/ability.module.ts | 3 ++ .../ability/handlers/view.ability-handler.ts | 16 ++++++ .../src/core/view/resolvers/view.resolver.ts | 15 ++++++ .../migration.sql | 11 ++++ server/src/database/schema.prisma | 4 +- 17 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 front/src/modules/views/graphql/mutations/deleteView.ts create mode 100644 server/src/database/migrations/20230816085431_apply_views_cascade_deletion/migration.sql diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 846da6d9c..ca3c9a5e4 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -982,6 +982,7 @@ export type Mutation = { deleteManyCompany: AffectedRows; deleteManyPerson: AffectedRows; deleteManyPipelineProgress: AffectedRows; + deleteManyView: AffectedRows; deleteManyViewSort: AffectedRows; deleteUserAccount: User; deleteWorkspaceMember: WorkspaceMember; @@ -1120,6 +1121,11 @@ export type MutationDeleteManyPipelineProgressArgs = { }; +export type MutationDeleteManyViewArgs = { + where?: InputMaybe; +}; + + export type MutationDeleteManyViewSortArgs = { where?: InputMaybe; }; @@ -3224,6 +3230,15 @@ export type CreateViewsMutationVariables = Exact<{ export type CreateViewsMutation = { __typename?: 'Mutation', createManyView: { __typename?: 'AffectedRows', count: number } }; + +export type DeleteViewsMutationVariables = Exact<{ + where: ViewWhereInput; +}>; + + +export type DeleteViewsMutation = { __typename?: 'Mutation', deleteManyView: { __typename?: 'AffectedRows', count: number } }; + + export type DeleteViewSortsMutationVariables = Exact<{ where: ViewSortWhereInput; }>; @@ -5984,6 +5999,41 @@ export function useCreateViewsMutation(baseOptions?: Apollo.MutationHookOptions< export type CreateViewsMutationHookResult = ReturnType; export type CreateViewsMutationResult = Apollo.MutationResult; export type CreateViewsMutationOptions = Apollo.BaseMutationOptions; + +export const DeleteViewsDocument = gql` + mutation DeleteViews($where: ViewWhereInput!) { + deleteManyView(where: $where) { + count + } +} + `; +export type DeleteViewsMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteViewsMutation__ + * + * To run a mutation, you first call `useDeleteViewsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteViewsMutation` 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 [deleteViewsMutation, { data, loading, error }] = useDeleteViewsMutation({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useDeleteViewsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteViewsDocument, options); + } +export type DeleteViewsMutationHookResult = ReturnType; +export type DeleteViewsMutationResult = Apollo.MutationResult; +export type DeleteViewsMutationOptions = Apollo.BaseMutationOptions; + export const DeleteViewSortsDocument = gql` mutation DeleteViewSorts($where: ViewSortWhereInput!) { deleteManyViewSort(where: $where) { diff --git a/front/src/modules/companies/table/components/CompanyTable.tsx b/front/src/modules/companies/table/components/CompanyTable.tsx index 680374084..3169330de 100644 --- a/front/src/modules/companies/table/components/CompanyTable.tsx +++ b/front/src/modules/companies/table/components/CompanyTable.tsx @@ -43,7 +43,8 @@ export function CompanyTable() { objectName: objectId, viewFieldDefinitions: companyViewFields, }); - const { updateSorts } = useViewSorts({ availableSorts }); + + const { handleSortsChange } = useViewSorts({ availableSorts }); const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport(); const filters = useRecoilScopedValue( @@ -85,7 +86,7 @@ export function CompanyTable() { viewName="All Companies" availableSorts={availableSorts} onColumnsChange={handleColumnsChange} - onSortsUpdate={currentViewId ? updateSorts : undefined} + onSortsUpdate={currentViewId ? handleSortsChange : undefined} onViewsChange={handleViewsChange} onImport={handleImport} updateEntityMutation={({ diff --git a/front/src/modules/people/table/components/PeopleTable.tsx b/front/src/modules/people/table/components/PeopleTable.tsx index a54ad0c1f..7f49de4b1 100644 --- a/front/src/modules/people/table/components/PeopleTable.tsx +++ b/front/src/modules/people/table/components/PeopleTable.tsx @@ -44,7 +44,7 @@ export function PeopleTable() { objectName: objectId, viewFieldDefinitions: peopleViewFields, }); - const { updateSorts } = useViewSorts({ availableSorts }); + const { handleSortsChange } = useViewSorts({ availableSorts }); const filters = useRecoilScopedValue( filtersScopedState, @@ -85,7 +85,7 @@ export function PeopleTable() { viewName="All People" availableSorts={availableSorts} onColumnsChange={handleColumnsChange} - onSortsUpdate={currentViewId ? updateSorts : undefined} + onSortsUpdate={currentViewId ? handleSortsChange : undefined} onViewsChange={handleViewsChange} onImport={handleImport} updateEntityMutation={({ diff --git a/front/src/modules/ui/table/components/EntityTableHeader.tsx b/front/src/modules/ui/table/components/EntityTableHeader.tsx index 89b4db82f..a5000b8bd 100644 --- a/front/src/modules/ui/table/components/EntityTableHeader.tsx +++ b/front/src/modules/ui/table/components/EntityTableHeader.tsx @@ -129,8 +129,7 @@ export function EntityTableHeader({ onColumnsChange }: EntityTableHeaderProps) { : column, ); - setColumns(nextColumns); - onColumnsChange?.(nextColumns); + (onColumnsChange ?? setColumns)(nextColumns); } set(resizeFieldOffsetState, 0); @@ -159,8 +158,7 @@ export function EntityTableHeader({ onColumnsChange }: EntityTableHeaderProps) { column.id === columnId ? { ...column, isVisible: true } : column, ); - setColumns(nextColumns); - onColumnsChange?.(nextColumns); + (onColumnsChange ?? setColumns)(nextColumns); }, [columns, onColumnsChange, setColumns], ); diff --git a/front/src/modules/ui/table/options/components/TableOptionsDropdownButton.tsx b/front/src/modules/ui/table/options/components/TableOptionsDropdownButton.tsx index fc2cc072d..59b519508 100644 --- a/front/src/modules/ui/table/options/components/TableOptionsDropdownButton.tsx +++ b/front/src/modules/ui/table/options/components/TableOptionsDropdownButton.tsx @@ -101,8 +101,7 @@ export const TableOptionsDropdownButton = ({ : column, ); - setColumns(nextColumns); - onColumnsChange?.(nextColumns); + (onColumnsChange ?? setColumns)(nextColumns); }, [columns, onColumnsChange, setColumns], ); diff --git a/front/src/modules/ui/table/options/components/TableViewsDropdownButton.tsx b/front/src/modules/ui/table/options/components/TableViewsDropdownButton.tsx index 9c7db5ec3..4e6ff5d13 100644 --- a/front/src/modules/ui/table/options/components/TableViewsDropdownButton.tsx +++ b/front/src/modules/ui/table/options/components/TableViewsDropdownButton.tsx @@ -8,10 +8,17 @@ import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem'; import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator'; import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton'; -import { IconChevronDown, IconList, IconPencil, IconPlus } from '@/ui/icon'; +import { + IconChevronDown, + IconList, + IconPencil, + IconPlus, + IconTrash, +} from '@/ui/icon'; import { currentTableViewIdState, currentTableViewState, + type TableView, tableViewEditModeState, tableViewsState, } from '@/ui/table/states/tableViewsState'; @@ -41,11 +48,13 @@ const StyledViewIcon = styled(IconList)` type TableViewsDropdownButtonProps = { defaultViewName: string; HotkeyScope: TableViewsHotkeyScope; + onViewsChange?: (views: TableView[]) => void; }; export const TableViewsDropdownButton = ({ defaultViewName, HotkeyScope, + onViewsChange, }: TableViewsDropdownButtonProps) => { const theme = useTheme(); const [isUnfolded, setIsUnfolded] = useState(false); @@ -54,7 +63,10 @@ export const TableViewsDropdownButton = ({ currentTableViewState, TableRecoilScopeContext, ); - const views = useRecoilScopedValue(tableViewsState, TableRecoilScopeContext); + const [views, setViews] = useRecoilScopedState( + tableViewsState, + TableRecoilScopeContext, + ); const [, setCurrentViewId] = useRecoilScopedState( currentTableViewIdState, TableRecoilScopeContext, @@ -88,6 +100,18 @@ export const TableViewsDropdownButton = ({ [setViewEditMode], ); + const handleDeleteViewButtonClick = useCallback( + (event: MouseEvent, viewId: string) => { + event.stopPropagation(); + + if (currentView?.id === viewId) setCurrentViewId(undefined); + + (onViewsChange ?? setViews)(views.filter((view) => view.id !== viewId)); + setIsUnfolded(false); + }, + [currentView?.id, onViewsChange, setCurrentViewId, setViews, views], + ); + useEffect(() => { isUnfolded ? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope) @@ -124,12 +148,18 @@ export const TableViewsDropdownButton = ({ {views.map((view) => ( handleEditViewButtonClick(event, view.id)} icon={} - /> - } + />, + handleDeleteViewButtonClick(event, view.id)} + icon={} + />, + ]} onClick={() => handleViewSelect(view.id)} > diff --git a/front/src/modules/ui/table/table-header/components/TableHeader.tsx b/front/src/modules/ui/table/table-header/components/TableHeader.tsx index bf4f15f61..7b3a14d56 100644 --- a/front/src/modules/ui/table/table-header/components/TableHeader.tsx +++ b/front/src/modules/ui/table/table-header/components/TableHeader.tsx @@ -64,6 +64,7 @@ export function TableHeader({ leftComponent={ } diff --git a/front/src/modules/views/graphql/mutations/deleteView.ts b/front/src/modules/views/graphql/mutations/deleteView.ts new file mode 100644 index 000000000..22f3c813a --- /dev/null +++ b/front/src/modules/views/graphql/mutations/deleteView.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_VIEWS = gql` + mutation DeleteViews($where: ViewWhereInput!) { + deleteManyView(where: $where) { + count + } + } +`; diff --git a/front/src/modules/views/hooks/useTableViewFields.ts b/front/src/modules/views/hooks/useTableViewFields.ts index 2a877dbac..b25efffda 100644 --- a/front/src/modules/views/hooks/useTableViewFields.ts +++ b/front/src/modules/views/hooks/useTableViewFields.ts @@ -131,6 +131,8 @@ export const useTableViewFields = ({ const handleColumnsChange = useCallback( async (nextColumns: ViewFieldDefinition[]) => { + setColumns(nextColumns); + const viewFieldsToCreate = nextColumns.filter( (nextColumn) => !columnsById[nextColumn.id], ); @@ -144,7 +146,7 @@ export const useTableViewFields = ({ ); await updateViewFields(viewFieldsToUpdate); }, - [columnsById, createViewFields, updateViewFields], + [columnsById, createViewFields, setColumns, updateViewFields], ); return { handleColumnsChange }; diff --git a/front/src/modules/views/hooks/useTableViews.ts b/front/src/modules/views/hooks/useTableViews.ts index eace96030..c70098ba9 100644 --- a/front/src/modules/views/hooks/useTableViews.ts +++ b/front/src/modules/views/hooks/useTableViews.ts @@ -11,6 +11,7 @@ import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoi import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { useCreateViewsMutation, + useDeleteViewsMutation, useGetViewsQuery, useUpdateViewMutation, ViewType, @@ -34,6 +35,7 @@ export const useTableViews = ({ const [createViewsMutation] = useCreateViewsMutation(); const [updateViewMutation] = useUpdateViewMutation(); + const [deleteViewsMutation] = useDeleteViewsMutation(); const createViews = useCallback( (views: TableView[]) => { @@ -72,6 +74,22 @@ export const useTableViews = ({ [updateViewMutation], ); + const deleteViews = useCallback( + (viewIds: string[]) => { + if (!viewIds.length) return; + + return deleteViewsMutation({ + variables: { + where: { + id: { in: viewIds }, + }, + }, + refetchQueries: [getOperationName(GET_VIEWS) ?? ''], + }); + }, + [deleteViewsMutation], + ); + useGetViewsQuery({ variables: { where: { @@ -90,6 +108,8 @@ export const useTableViews = ({ const handleViewsChange = useCallback( async (nextViews: TableView[]) => { + setViews(nextViews); + const viewsToCreate = nextViews.filter( (nextView) => !viewsById[nextView.id], ); @@ -101,8 +121,14 @@ export const useTableViews = ({ viewsById[nextView.id].name !== nextView.name, ); await updateViewFields(viewsToUpdate); + + const nextViewIds = nextViews.map((nextView) => nextView.id); + const viewIdsToDelete = Object.keys(viewsById).filter( + (previousViewId) => !nextViewIds.includes(previousViewId), + ); + return deleteViews(viewIdsToDelete); }, - [createViews, updateViewFields, viewsById], + [createViews, deleteViews, setViews, updateViewFields, viewsById], ); return { handleViewsChange }; diff --git a/front/src/modules/views/hooks/useViewSorts.ts b/front/src/modules/views/hooks/useViewSorts.ts index 1223cf2a4..a91ff4ed3 100644 --- a/front/src/modules/views/hooks/useViewSorts.ts +++ b/front/src/modules/views/hooks/useViewSorts.ts @@ -136,10 +136,12 @@ export const useViewSorts = ({ [currentViewId, deleteViewSortsMutation], ); - const updateSorts = useCallback( + const handleSortsChange = useCallback( async (nextSorts: SelectedSortType[]) => { if (!currentViewId) return; + setSorts(nextSorts); + const sortsToCreate = nextSorts.filter( (nextSort) => !sortsByKey[nextSort.key], ); @@ -162,10 +164,11 @@ export const useViewSorts = ({ createViewSorts, currentViewId, deleteViewSorts, + setSorts, sortsByKey, updateViewSorts, ], ); - return { updateSorts }; + return { handleSortsChange }; }; diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index 39d26b657..772872561 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -140,17 +140,17 @@ export class AbilityFactory { can(AbilityAction.Read, 'View', { workspaceId: workspace.id }); can(AbilityAction.Create, 'View', { workspaceId: workspace.id }); can(AbilityAction.Update, 'View', { workspaceId: workspace.id }); + can(AbilityAction.Delete, 'View', { workspaceId: workspace.id }); // ViewField can(AbilityAction.Read, 'ViewField', { workspaceId: workspace.id }); can(AbilityAction.Create, 'ViewField', { workspaceId: workspace.id }); can(AbilityAction.Update, 'ViewField', { workspaceId: workspace.id }); + //Favorite can(AbilityAction.Read, 'Favorite', { workspaceId: workspace.id }); can(AbilityAction.Create, 'Favorite'); - can(AbilityAction.Delete, 'Favorite', { - workspaceId: workspace.id, - }); + can(AbilityAction.Delete, 'Favorite', { workspaceId: workspace.id }); // ViewSort can(AbilityAction.Read, 'ViewSort', { workspaceId: workspace.id }); diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts index 885342569..322b68022 100644 --- a/server/src/ability/ability.module.ts +++ b/server/src/ability/ability.module.ts @@ -114,6 +114,7 @@ import { CreateViewAbilityHandler, ReadViewAbilityHandler, UpdateViewAbilityHandler, + DeleteViewAbilityHandler, } from './handlers/view.ability-handler'; @Global() @@ -207,6 +208,7 @@ import { ReadViewAbilityHandler, CreateViewAbilityHandler, UpdateViewAbilityHandler, + DeleteViewAbilityHandler, // ViewField ReadViewFieldAbilityHandler, CreateViewFieldAbilityHandler, @@ -305,6 +307,7 @@ import { ReadViewAbilityHandler, CreateViewAbilityHandler, UpdateViewAbilityHandler, + DeleteViewAbilityHandler, // ViewField ReadViewFieldAbilityHandler, CreateViewFieldAbilityHandler, diff --git a/server/src/ability/handlers/view.ability-handler.ts b/server/src/ability/handlers/view.ability-handler.ts index eec45c15c..8d5bf7429 100644 --- a/server/src/ability/handlers/view.ability-handler.ts +++ b/server/src/ability/handlers/view.ability-handler.ts @@ -77,3 +77,19 @@ export class UpdateViewAbilityHandler implements IAbilityHandler { return ability.can(AbilityAction.Update, subject('View', view)); } } + +@Injectable() +export class DeleteViewAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const view = await this.prismaService.client.view.findFirst({ + where: args.where, + }); + assert(view, '', NotFoundException); + + return ability.can(AbilityAction.Delete, subject('View', view)); + } +} diff --git a/server/src/core/view/resolvers/view.resolver.ts b/server/src/core/view/resolvers/view.resolver.ts index 5b46208da..fd96564ff 100644 --- a/server/src/core/view/resolvers/view.resolver.ts +++ b/server/src/core/view/resolvers/view.resolver.ts @@ -7,6 +7,7 @@ import { Prisma, Workspace } from '@prisma/client'; import { AppAbility } from 'src/ability/ability.factory'; import { CreateViewAbilityHandler, + DeleteViewAbilityHandler, ReadViewAbilityHandler, UpdateViewAbilityHandler, } from 'src/ability/handlers/view.ability-handler'; @@ -25,6 +26,7 @@ import { UpdateOneViewArgs } from 'src/core/@generated/view/update-one-view.args import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { AffectedRows } from 'src/core/@generated/prisma/affected-rows.output'; import { CreateManyViewArgs } from 'src/core/@generated/view/create-many-view.args'; +import { DeleteManyViewArgs } from 'src/core/@generated/view/delete-many-view.args'; @UseGuards(JwtAuthGuard) @Resolver(() => View) @@ -84,4 +86,17 @@ export class ViewResolver { select: prismaSelect.value, } as Prisma.ViewUpdateArgs); } + + @Mutation(() => AffectedRows, { + nullable: false, + }) + @UseGuards(AbilityGuard) + @CheckAbilities(DeleteViewAbilityHandler) + async deleteManyView( + @Args() args: DeleteManyViewArgs, + ): Promise { + return this.viewService.deleteMany({ + where: args.where, + }); + } } diff --git a/server/src/database/migrations/20230816085431_apply_views_cascade_deletion/migration.sql b/server/src/database/migrations/20230816085431_apply_views_cascade_deletion/migration.sql new file mode 100644 index 000000000..271070b01 --- /dev/null +++ b/server/src/database/migrations/20230816085431_apply_views_cascade_deletion/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "viewFields" DROP CONSTRAINT "viewFields_viewId_fkey"; + +-- DropForeignKey +ALTER TABLE "viewSorts" DROP CONSTRAINT "viewSorts_viewId_fkey"; + +-- AddForeignKey +ALTER TABLE "viewSorts" ADD CONSTRAINT "viewSorts_viewId_fkey" FOREIGN KEY ("viewId") REFERENCES "views"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "viewFields" ADD CONSTRAINT "viewFields_viewId_fkey" FOREIGN KEY ("viewId") REFERENCES "views"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index bd2fc90ba..0215d7286 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -606,7 +606,7 @@ model ViewSort { key String name String - view View @relation(fields: [viewId], references: [id]) + view View @relation(fields: [viewId], references: [id], onDelete: Cascade) viewId String /// @TypeGraphQL.omit(input: true, output: true) @@ -629,7 +629,7 @@ model ViewField { objectName String sizeInPx Int - view View? @relation(fields: [viewId], references: [id]) + view View? @relation(fields: [viewId], references: [id], onDelete: Cascade) viewId String? /// @TypeGraphQL.omit(input: true, output: true)