From 91670df1db152b146d398cc4dac11cfa3b9f1bdb Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 10 Aug 2023 08:03:52 -0700 Subject: [PATCH 1/7] Fix doc --- docs/docs/developer/additional/windows-wsl-setup copy.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/developer/additional/windows-wsl-setup copy.mdx b/docs/docs/developer/additional/windows-wsl-setup copy.mdx index d6a3a9f61c..84e4cdbc16 100644 --- a/docs/docs/developer/additional/windows-wsl-setup copy.mdx +++ b/docs/docs/developer/additional/windows-wsl-setup copy.mdx @@ -22,7 +22,7 @@ Upon restart, a powershell window will open and install Ubuntu. This may take a You will be prompted to create a username and password for your Ubuntu installation.
- Visual Studio Code: Open in container + Visual Studio Code: Open in container
## Setup your developer environment From 428acf4a13e8ec4e7d7f99eefccd9809d1c16351 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 10 Aug 2023 08:30:56 -0700 Subject: [PATCH 2/7] Fix windows-setup doc url --- .../{windows-wsl-setup copy.mdx => windows-wsl-setup.mdx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/docs/developer/additional/{windows-wsl-setup copy.mdx => windows-wsl-setup.mdx} (100%) diff --git a/docs/docs/developer/additional/windows-wsl-setup copy.mdx b/docs/docs/developer/additional/windows-wsl-setup.mdx similarity index 100% rename from docs/docs/developer/additional/windows-wsl-setup copy.mdx rename to docs/docs/developer/additional/windows-wsl-setup.mdx From 0f364cc9e72e1d03e5a5b65fd6298cb44414a16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Thu, 10 Aug 2023 18:14:28 +0200 Subject: [PATCH 3/7] feat: add views and viewSorts tables (#1131) * feat: add views table Closes #1120 * feat: add viewSorts table Closes #1120 --- front/src/generated/graphql.tsx | 176 ++++++++++++++++++ server/src/ability/ability.factory.ts | 4 + .../view/resolvers/view-field.resolver.ts | 2 +- .../workspace/services/workspace.service.ts | 8 + .../migration.sql | 40 ++++ .../migration.sql | 19 ++ server/src/database/schema.prisma | 55 +++++- .../utils/prisma-select/model-select-map.ts | 2 + 8 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 server/src/database/migrations/20230809143432_add_views_table/migration.sql create mode 100644 server/src/database/migrations/20230809143836_add_view_sorts_table/migration.sql diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 82a519fb7b..49a69b1622 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -846,6 +846,20 @@ export type EnumPipelineProgressableTypeFilter = { notIn?: InputMaybe>; }; +export type EnumViewSortDirectionFilter = { + equals?: InputMaybe; + in?: InputMaybe>; + not?: InputMaybe; + notIn?: InputMaybe>; +}; + +export type EnumViewTypeFilter = { + equals?: InputMaybe; + in?: InputMaybe>; + not?: InputMaybe; + notIn?: InputMaybe>; +}; + export enum FileFolder { Attachment = 'Attachment', PersonPicture = 'PersonPicture', @@ -1178,6 +1192,20 @@ export type NestedEnumPipelineProgressableTypeFilter = { notIn?: InputMaybe>; }; +export type NestedEnumViewSortDirectionFilter = { + equals?: InputMaybe; + in?: InputMaybe>; + not?: InputMaybe; + notIn?: InputMaybe>; +}; + +export type NestedEnumViewTypeFilter = { + equals?: InputMaybe; + in?: InputMaybe>; + not?: InputMaybe; + notIn?: InputMaybe>; +}; + export type NestedIntFilter = { equals?: InputMaybe; gt?: InputMaybe; @@ -2161,6 +2189,20 @@ export type Verify = { user: User; }; +export type View = { + __typename?: 'View'; + fields?: Maybe>; + id: Scalars['ID']; + name: Scalars['String']; + objectId: Scalars['String']; + sorts?: Maybe>; + type: ViewType; +}; + +export type ViewCreateNestedOneWithoutFieldsInput = { + connect?: InputMaybe; +}; + export type ViewField = { __typename?: 'ViewField'; fieldName: Scalars['String']; @@ -2169,6 +2211,8 @@ export type ViewField = { isVisible: Scalars['Boolean']; objectName: Scalars['String']; sizeInPx: Scalars['Int']; + view?: Maybe; + viewId?: Maybe; }; export type ViewFieldCreateInput = { @@ -2178,6 +2222,7 @@ export type ViewFieldCreateInput = { isVisible: Scalars['Boolean']; objectName: Scalars['String']; sizeInPx: Scalars['Int']; + view?: InputMaybe; }; export type ViewFieldCreateManyInput = { @@ -2187,6 +2232,17 @@ export type ViewFieldCreateManyInput = { isVisible: Scalars['Boolean']; objectName: Scalars['String']; sizeInPx: Scalars['Int']; + viewId?: InputMaybe; +}; + +export type ViewFieldListRelationFilter = { + every?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; +}; + +export type ViewFieldOrderByRelationAggregateInput = { + _count?: InputMaybe; }; export type ViewFieldOrderByWithRelationInput = { @@ -2196,6 +2252,8 @@ export type ViewFieldOrderByWithRelationInput = { isVisible?: InputMaybe; objectName?: InputMaybe; sizeInPx?: InputMaybe; + view?: InputMaybe; + viewId?: InputMaybe; }; export enum ViewFieldScalarFieldEnum { @@ -2205,6 +2263,7 @@ export enum ViewFieldScalarFieldEnum { IsVisible = 'isVisible', ObjectName = 'objectName', SizeInPx = 'sizeInPx', + ViewId = 'viewId', WorkspaceId = 'workspaceId' } @@ -2215,6 +2274,7 @@ export type ViewFieldUpdateInput = { isVisible?: InputMaybe; objectName?: InputMaybe; sizeInPx?: InputMaybe; + view?: InputMaybe; }; export type ViewFieldUpdateManyWithoutWorkspaceNestedInput = { @@ -2233,10 +2293,122 @@ export type ViewFieldWhereInput = { isVisible?: InputMaybe; objectName?: InputMaybe; sizeInPx?: InputMaybe; + view?: InputMaybe; + viewId?: InputMaybe; }; export type ViewFieldWhereUniqueInput = { id?: InputMaybe; + workspaceId_viewId_objectName_fieldName?: InputMaybe; +}; + +export type ViewFieldWorkspaceIdViewIdObjectNameFieldNameCompoundUniqueInput = { + fieldName: Scalars['String']; + objectName: Scalars['String']; + viewId: Scalars['String']; +}; + +export type ViewOrderByWithRelationInput = { + fields?: InputMaybe; + id?: InputMaybe; + name?: InputMaybe; + objectId?: InputMaybe; + sorts?: InputMaybe; + type?: InputMaybe; +}; + +export type ViewRelationFilter = { + is?: InputMaybe; + isNot?: InputMaybe; +}; + +export type ViewSort = { + __typename?: 'ViewSort'; + direction: ViewSortDirection; + key: Scalars['String']; + name: Scalars['String']; + view: View; + viewId: Scalars['String']; +}; + +export enum ViewSortDirection { + Asc = 'asc', + Desc = 'desc' +} + +export type ViewSortListRelationFilter = { + every?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; +}; + +export type ViewSortOrderByRelationAggregateInput = { + _count?: InputMaybe; +}; + +export type ViewSortUpdateManyWithoutWorkspaceNestedInput = { + connect?: InputMaybe>; + disconnect?: InputMaybe>; + set?: InputMaybe>; +}; + +export type ViewSortViewIdKeyCompoundUniqueInput = { + key: Scalars['String']; + viewId: Scalars['String']; +}; + +export type ViewSortWhereInput = { + AND?: InputMaybe>; + NOT?: InputMaybe>; + OR?: InputMaybe>; + direction?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; + view?: InputMaybe; + viewId?: InputMaybe; +}; + +export type ViewSortWhereUniqueInput = { + viewId_key?: InputMaybe; +}; + +export enum ViewType { + Pipeline = 'Pipeline', + Table = 'Table' +} + +export type ViewUpdateManyWithoutWorkspaceNestedInput = { + connect?: InputMaybe>; + disconnect?: InputMaybe>; + set?: InputMaybe>; +}; + +export type ViewUpdateOneWithoutFieldsNestedInput = { + connect?: InputMaybe; + disconnect?: InputMaybe; +}; + +export type ViewWhereInput = { + AND?: InputMaybe>; + NOT?: InputMaybe>; + OR?: InputMaybe>; + fields?: InputMaybe; + id?: InputMaybe; + name?: InputMaybe; + objectId?: InputMaybe; + sorts?: InputMaybe; + type?: InputMaybe; +}; + +export type ViewWhereUniqueInput = { + id?: InputMaybe; + workspaceId_type_objectId_name?: InputMaybe; +}; + +export type ViewWorkspaceIdTypeObjectIdNameCompoundUniqueInput = { + name: Scalars['String']; + objectId: Scalars['String']; + type: ViewType; }; export type Workspace = { @@ -2258,6 +2430,8 @@ export type Workspace = { pipelines?: Maybe>; updatedAt: Scalars['DateTime']; viewFields?: Maybe>; + viewSorts?: Maybe>; + views?: Maybe>; workspaceMember?: Maybe>; }; @@ -2337,6 +2511,8 @@ export type WorkspaceUpdateInput = { pipelines?: InputMaybe; updatedAt?: InputMaybe; viewFields?: InputMaybe; + viewSorts?: InputMaybe; + views?: InputMaybe; workspaceMember?: InputMaybe; }; diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index 3d275d804e..60b307669b 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -18,6 +18,8 @@ import { PipelineProgress, UserSettings, ViewField, + View, + ViewSort, } from '@prisma/client'; import { AbilityAction } from './ability.action'; @@ -37,7 +39,9 @@ type SubjectsAbility = Subjects<{ PipelineProgress: PipelineProgress; Attachment: Attachment; UserSettings: UserSettings; + View: View; ViewField: ViewField; + ViewSort: ViewSort; }>; export type AppAbility = PureAbility< diff --git a/server/src/core/view/resolvers/view-field.resolver.ts b/server/src/core/view/resolvers/view-field.resolver.ts index ea9a94cdc6..09d9efd183 100644 --- a/server/src/core/view/resolvers/view-field.resolver.ts +++ b/server/src/core/view/resolvers/view-field.resolver.ts @@ -45,7 +45,7 @@ export class ViewFieldResolver { ): Promise> { return this.viewFieldService.create({ data: { - ...args.data, + ...(args.data as Prisma.ViewFieldCreateInput), workspace: { connect: { id: workspace.id } }, }, select: prismaSelect.value, diff --git a/server/src/core/workspace/services/workspace.service.ts b/server/src/core/workspace/services/workspace.service.ts index 19ce7105b7..a56a744f87 100644 --- a/server/src/core/workspace/services/workspace.service.ts +++ b/server/src/core/workspace/services/workspace.service.ts @@ -111,7 +111,9 @@ export class WorkspaceService { comment, activityTarget, activity, + view, viewField, + viewSort, } = this.prismaService.client; const activitys = await activity.findMany({ @@ -151,9 +153,15 @@ export class WorkspaceService { activity.deleteMany({ where, }), + view.deleteMany({ + where, + }), viewField.deleteMany({ where, }), + viewSort.deleteMany({ + where, + }), refreshToken.deleteMany({ where: { userId }, }), diff --git a/server/src/database/migrations/20230809143432_add_views_table/migration.sql b/server/src/database/migrations/20230809143432_add_views_table/migration.sql new file mode 100644 index 0000000000..17ff87f2ec --- /dev/null +++ b/server/src/database/migrations/20230809143432_add_views_table/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - A unique constraint covering the columns `[workspaceId,viewId,objectName,fieldName]` on the table `viewFields` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "ViewType" AS ENUM ('Table', 'Pipeline'); + +-- AlterTable +ALTER TABLE "viewFields" ADD COLUMN "viewId" TEXT; + +-- CreateTable +CREATE TABLE "views" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "objectId" TEXT NOT NULL, + "type" "ViewType" NOT NULL, + "workspaceId" TEXT NOT NULL, + + CONSTRAINT "views_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "views_workspaceId_type_objectId_name_key" ON "views"("workspaceId", "type", "objectId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "viewFields_workspaceId_viewId_objectName_fieldName_key" ON "viewFields"("workspaceId", "viewId", "objectName", "fieldName"); + +-- AddForeignKey +ALTER TABLE "pipeline_progresses" ADD CONSTRAINT "pipeline_progresses_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pipeline_progresses" ADD CONSTRAINT "pipeline_progresses_personId_fkey" FOREIGN KEY ("personId") REFERENCES "people"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "views" ADD CONSTRAINT "views_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "viewFields" ADD CONSTRAINT "viewFields_viewId_fkey" FOREIGN KEY ("viewId") REFERENCES "views"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/src/database/migrations/20230809143836_add_view_sorts_table/migration.sql b/server/src/database/migrations/20230809143836_add_view_sorts_table/migration.sql new file mode 100644 index 0000000000..870add1fa7 --- /dev/null +++ b/server/src/database/migrations/20230809143836_add_view_sorts_table/migration.sql @@ -0,0 +1,19 @@ +-- CreateEnum +CREATE TYPE "ViewSortDirection" AS ENUM ('asc', 'desc'); + +-- CreateTable +CREATE TABLE "viewSorts" ( + "direction" "ViewSortDirection" NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "viewId" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + + CONSTRAINT "viewSorts_pkey" PRIMARY KEY ("viewId","key") +); + +-- AddForeignKey +ALTER TABLE "viewSorts" ADD CONSTRAINT "viewSorts_viewId_fkey" FOREIGN KEY ("viewId") REFERENCES "views"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "viewSorts" ADD CONSTRAINT "viewSorts_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index 9e23288956..b02c78a93d 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -174,6 +174,8 @@ model Workspace { pipelineProgresses PipelineProgress[] activityTargets ActivityTarget[] viewFields ViewField[] + views View[] + viewSorts ViewSort[] /// @TypeGraphQL.omit(input: true, output: true) deletedAt DateTime? @@ -266,7 +268,7 @@ model Person { linkedinUrl String? /// @Validator.IsString() /// @Validator.IsOptional() - xUrl String? + xUrl String? /// @Validator.IsString() /// @Validator.IsOptional() jobTitle String? @@ -557,6 +559,53 @@ model Attachment { @@map("attachments") } +enum ViewType { + Table + Pipeline +} + +model View { + /// @Validator.IsString() + /// @Validator.IsOptional() + id String @id @default(uuid()) + + fields ViewField[] + name String + objectId String + sorts ViewSort[] + type ViewType + + /// @TypeGraphQL.omit(input: true, output: true) + workspace Workspace @relation(fields: [workspaceId], references: [id]) + /// @TypeGraphQL.omit(input: true, output: true) + workspaceId String + + @@unique([workspaceId, type, objectId, name]) + @@map("views") +} + +enum ViewSortDirection { + asc + desc +} + +model ViewSort { + direction ViewSortDirection + key String + name String + + view View @relation(fields: [viewId], references: [id]) + viewId String + + /// @TypeGraphQL.omit(input: true, output: true) + workspace Workspace @relation(fields: [workspaceId], references: [id]) + /// @TypeGraphQL.omit(input: true, output: true) + workspaceId String + + @@id([viewId, key]) + @@map("viewSorts") +} + model ViewField { /// @Validator.IsString() /// @Validator.IsOptional() @@ -568,10 +617,14 @@ model ViewField { objectName String sizeInPx Int + view View? @relation(fields: [viewId], references: [id]) + viewId String? + /// @TypeGraphQL.omit(input: true, output: true) workspace Workspace @relation(fields: [workspaceId], references: [id]) /// @TypeGraphQL.omit(input: true, output: true) workspaceId String + @@unique([workspaceId, viewId, objectName, fieldName]) @@map("viewFields") } diff --git a/server/src/utils/prisma-select/model-select-map.ts b/server/src/utils/prisma-select/model-select-map.ts index 653508af35..052371e71d 100644 --- a/server/src/utils/prisma-select/model-select-map.ts +++ b/server/src/utils/prisma-select/model-select-map.ts @@ -16,5 +16,7 @@ export type ModelSelectMap = { PipelineStage: Prisma.PipelineStageSelect; PipelineProgress: Prisma.PipelineProgressSelect; Attachment: Prisma.AttachmentSelect; + View: Prisma.ViewSelect; + ViewSort: Prisma.ViewSortSelect; ViewField: Prisma.ViewFieldSelect; }; From c91844071a522b2de7921119508f51d37ab88024 Mon Sep 17 00:00:00 2001 From: brendanlaschke Date: Thu, 10 Aug 2023 18:17:58 +0200 Subject: [PATCH 4/7] Add task to action bar (#1153) - add task to action bar --- .../hooks/useOpenCreateActivityDrawer.ts | 34 ++++---- ...enCreateActivityDrawerForSelectedRowIds.ts | 77 ++----------------- .../timeline/components/Timeline.tsx | 8 +- ...leActionBarButtonCreateActivityCompany.tsx | 18 ++++- ...bleActionBarButtonCreateActivityPeople.tsx | 18 ++++- .../TableActionBarButtonOpenTasks.tsx | 17 ++++ front/src/sync-hooks/AuthAutoRouter.tsx | 24 +++++- 7 files changed, 92 insertions(+), 104 deletions(-) create mode 100644 front/src/modules/ui/table/action-bar/components/TableActionBarButtonOpenTasks.tsx diff --git a/front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts b/front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts index 90896062ca..535f735804 100644 --- a/front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts +++ b/front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts @@ -37,7 +37,7 @@ export function useOpenCreateActivityDrawer() { return function openCreateActivityDrawer( type: ActivityType, - entity?: CommentableEntity, + entities?: CommentableEntity[], ) { const now = new Date().toISOString(); @@ -52,23 +52,19 @@ export function useOpenCreateActivityDrawer() { type: type, activityTargets: { createMany: { - data: entity - ? [ - { - commentableId: entity.id, - commentableType: entity.type, - companyId: - entity.type === CommentableType.Company - ? entity.id - : null, - personId: - entity.type === CommentableType.Person - ? entity.id - : null, - id: v4(), - createdAt: now, - }, - ] + data: entities + ? entities.map((entity) => ({ + commentableId: entity.id, + commentableType: entity.type, + companyId: + entity.type === CommentableType.Company + ? entity.id + : null, + personId: + entity.type === CommentableType.Person ? entity.id : null, + id: v4(), + createdAt: now, + })) : [], skipDuplicates: true, }, @@ -85,7 +81,7 @@ export function useOpenCreateActivityDrawer() { onCompleted(data) { setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); setViewableActivityId(data.createOneActivity.id); - setCommentableEntityArray(entity ? [entity] : []); + setCommentableEntityArray(entities ?? []); openRightDrawer(RightDrawerPages.CreateActivity); }, }); diff --git a/front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts b/front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts index b363d9af2a..554218d254 100644 --- a/front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts +++ b/front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts @@ -1,41 +1,19 @@ -import { getOperationName } from '@apollo/client/utilities/graphql/getFromAST'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { v4 } from 'uuid'; +import { useRecoilValue } from 'recoil'; -import { currentUserState } from '@/auth/states/currentUserState'; -import { GET_COMPANIES } from '@/companies/queries'; -import { GET_PEOPLE } from '@/people/queries'; -import { useRightDrawer } from '@/ui/right-drawer/hooks/useRightDrawer'; -import { RightDrawerHotkeyScope } from '@/ui/right-drawer/types/RightDrawerHotkeyScope'; -import { RightDrawerPages } from '@/ui/right-drawer/types/RightDrawerPages'; import { selectedRowIdsSelector } from '@/ui/table/states/selectedRowIdsSelector'; -import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; -import { - ActivityType, - CommentableType, - useCreateActivityMutation, -} from '~/generated/graphql'; +import { ActivityType, CommentableType } from '~/generated/graphql'; -import { GET_ACTIVITIES_BY_TARGETS, GET_ACTIVITY } from '../queries'; -import { commentableEntityArrayState } from '../states/commentableEntityArrayState'; -import { viewableActivityIdState } from '../states/viewableActivityIdState'; import { CommentableEntity } from '../types/CommentableEntity'; +import { useOpenCreateActivityDrawer } from './useOpenCreateActivityDrawer'; + export function useOpenCreateActivityDrawerForSelectedRowIds() { - const { openRightDrawer } = useRightDrawer(); - const [createActivityMutation] = useCreateActivityMutation(); - const currentUser = useRecoilValue(currentUserState); - const [, setViewableActivityId] = useRecoilState(viewableActivityIdState); - - const setHotkeyScope = useSetHotkeyScope(); - - const [, setCommentableEntityArray] = useRecoilState( - commentableEntityArrayState, - ); - const selectedEntityIds = useRecoilValue(selectedRowIdsSelector); + const openCreateActivityDrawer = useOpenCreateActivityDrawer(); + return function openCreateCommentDrawerForSelectedRowIds( + type: ActivityType, entityType: CommentableType, ) { const commentableEntityArray: CommentableEntity[] = selectedEntityIds.map( @@ -44,45 +22,6 @@ export function useOpenCreateActivityDrawerForSelectedRowIds() { id, }), ); - const now = new Date().toISOString(); - - createActivityMutation({ - variables: { - data: { - id: v4(), - createdAt: now, - updatedAt: now, - author: { connect: { id: currentUser?.id ?? '' } }, - type: ActivityType.Note, - activityTargets: { - createMany: { - data: commentableEntityArray.map((entity) => ({ - commentableId: entity.id, - commentableType: entity.type, - id: v4(), - createdAt: new Date().toISOString(), - companyId: - entity.type === CommentableType.Company ? entity.id : null, - personId: - entity.type === CommentableType.Person ? entity.id : null, - })), - skipDuplicates: true, - }, - }, - }, - }, - refetchQueries: [ - getOperationName(GET_COMPANIES) ?? '', - getOperationName(GET_PEOPLE) ?? '', - getOperationName(GET_ACTIVITY) ?? '', - getOperationName(GET_ACTIVITIES_BY_TARGETS) ?? '', - ], - onCompleted(data) { - setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); - setViewableActivityId(data.createOneActivity.id); - setCommentableEntityArray(commentableEntityArray); - openRightDrawer(RightDrawerPages.CreateActivity); - }, - }); + openCreateActivityDrawer(type, commentableEntityArray); }; } diff --git a/front/src/modules/activities/timeline/components/Timeline.tsx b/front/src/modules/activities/timeline/components/Timeline.tsx index c395642b53..a5dd649da1 100644 --- a/front/src/modules/activities/timeline/components/Timeline.tsx +++ b/front/src/modules/activities/timeline/components/Timeline.tsx @@ -121,8 +121,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) { No activity yet Create one: openCreateActivity(ActivityType.Note, entity)} - onTaskClick={() => openCreateActivity(ActivityType.Task, entity)} + onNoteClick={() => openCreateActivity(ActivityType.Note, [entity])} + onTaskClick={() => openCreateActivity(ActivityType.Task, [entity])} /> ); @@ -132,8 +132,8 @@ export function Timeline({ entity }: { entity: CommentableEntity }) { openCreateActivity(ActivityType.Note, entity)} - onTaskClick={() => openCreateActivity(ActivityType.Task, entity)} + onNoteClick={() => openCreateActivity(ActivityType.Note, [entity])} + onTaskClick={() => openCreateActivity(ActivityType.Task, [entity])} /> diff --git a/front/src/modules/companies/table/components/TableActionBarButtonCreateActivityCompany.tsx b/front/src/modules/companies/table/components/TableActionBarButtonCreateActivityCompany.tsx index b06dafaef6..cfb5eefdad 100644 --- a/front/src/modules/companies/table/components/TableActionBarButtonCreateActivityCompany.tsx +++ b/front/src/modules/companies/table/components/TableActionBarButtonCreateActivityCompany.tsx @@ -1,14 +1,24 @@ import { useOpenCreateActivityDrawerForSelectedRowIds } from '@/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds'; import { TableActionBarButtonToggleComments } from '@/ui/table/action-bar/components/TableActionBarButtonOpenComments'; -import { CommentableType } from '~/generated/graphql'; +import { TableActionBarButtonToggleTasks } from '@/ui/table/action-bar/components/TableActionBarButtonOpenTasks'; +import { ActivityType, CommentableType } from '~/generated/graphql'; export function TableActionBarButtonCreateActivityCompany() { const openCreateActivityRightDrawer = useOpenCreateActivityDrawerForSelectedRowIds(); - async function handleButtonClick() { - openCreateActivityRightDrawer(CommentableType.Company); + async function handleButtonClick(type: ActivityType) { + openCreateActivityRightDrawer(type, CommentableType.Company); } - return ; + return ( + <> + handleButtonClick(ActivityType.Note)} + /> + handleButtonClick(ActivityType.Task)} + /> + + ); } diff --git a/front/src/modules/people/table/components/TableActionBarButtonCreateActivityPeople.tsx b/front/src/modules/people/table/components/TableActionBarButtonCreateActivityPeople.tsx index 31197a5b91..9d8e271ca8 100644 --- a/front/src/modules/people/table/components/TableActionBarButtonCreateActivityPeople.tsx +++ b/front/src/modules/people/table/components/TableActionBarButtonCreateActivityPeople.tsx @@ -1,14 +1,24 @@ import { useOpenCreateActivityDrawerForSelectedRowIds } from '@/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds'; import { TableActionBarButtonToggleComments } from '@/ui/table/action-bar/components/TableActionBarButtonOpenComments'; -import { CommentableType } from '~/generated/graphql'; +import { TableActionBarButtonToggleTasks } from '@/ui/table/action-bar/components/TableActionBarButtonOpenTasks'; +import { ActivityType, CommentableType } from '~/generated/graphql'; export function TableActionBarButtonCreateActivityPeople() { const openCreateActivityRightDrawer = useOpenCreateActivityDrawerForSelectedRowIds(); - async function handleButtonClick() { - openCreateActivityRightDrawer(CommentableType.Person); + async function handleButtonClick(type: ActivityType) { + openCreateActivityRightDrawer(type, CommentableType.Person); } - return ; + return ( + <> + handleButtonClick(ActivityType.Note)} + /> + handleButtonClick(ActivityType.Task)} + /> + + ); } diff --git a/front/src/modules/ui/table/action-bar/components/TableActionBarButtonOpenTasks.tsx b/front/src/modules/ui/table/action-bar/components/TableActionBarButtonOpenTasks.tsx new file mode 100644 index 0000000000..b407ec239b --- /dev/null +++ b/front/src/modules/ui/table/action-bar/components/TableActionBarButtonOpenTasks.tsx @@ -0,0 +1,17 @@ +import { IconCheckbox } from '@/ui/icon/index'; + +import { EntityTableActionBarButton } from './EntityTableActionBarButton'; + +type OwnProps = { + onClick: () => void; +}; + +export function TableActionBarButtonToggleTasks({ onClick }: OwnProps) { + return ( + } + onClick={onClick} + /> + ); +} diff --git a/front/src/sync-hooks/AuthAutoRouter.tsx b/front/src/sync-hooks/AuthAutoRouter.tsx index 2ff66c3f3d..a29f94be94 100644 --- a/front/src/sync-hooks/AuthAutoRouter.tsx +++ b/front/src/sync-hooks/AuthAutoRouter.tsx @@ -186,14 +186,22 @@ export function AuthAutoRouter() { label: 'Create Task', type: CommandType.Create, icon: , - onCommandClick: () => openCreateActivity(ActivityType.Task, entity), + onCommandClick: () => + openCreateActivity( + ActivityType.Task, + entity ? [entity] : undefined, + ), }, { to: '', label: 'Create Note', type: CommandType.Create, icon: , - onCommandClick: () => openCreateActivity(ActivityType.Note, entity), + onCommandClick: () => + openCreateActivity( + ActivityType.Note, + entity ? [entity] : undefined, + ), }, ]); break; @@ -212,14 +220,22 @@ export function AuthAutoRouter() { label: 'Create Task', type: CommandType.Create, icon: , - onCommandClick: () => openCreateActivity(ActivityType.Task, entity), + onCommandClick: () => + openCreateActivity( + ActivityType.Task, + entity ? [entity] : undefined, + ), }, { to: '', label: 'Create Note', type: CommandType.Create, icon: , - onCommandClick: () => openCreateActivity(ActivityType.Note, entity), + onCommandClick: () => + openCreateActivity( + ActivityType.Note, + entity ? [entity] : undefined, + ), }, ]); break; From ee5ac11f9848e416fc5f5d26f1fc4e2351251e48 Mon Sep 17 00:00:00 2001 From: Srikar Samudrala Date: Thu, 10 Aug 2023 22:05:09 +0530 Subject: [PATCH 5/7] Adds URL validation (#1155) --- .../GenericEditableURLCellEditMode.tsx | 3 ++ front/src/utils/__tests__/is-url.test.ts | 35 +++++++++++++++++++ front/src/utils/is-url.ts | 10 ++++++ 3 files changed, 48 insertions(+) create mode 100644 front/src/utils/__tests__/is-url.test.ts create mode 100644 front/src/utils/is-url.ts diff --git a/front/src/modules/ui/table/editable-cell/type/components/GenericEditableURLCellEditMode.tsx b/front/src/modules/ui/table/editable-cell/type/components/GenericEditableURLCellEditMode.tsx index 23da20a191..605c9d87b7 100644 --- a/front/src/modules/ui/table/editable-cell/type/components/GenericEditableURLCellEditMode.tsx +++ b/front/src/modules/ui/table/editable-cell/type/components/GenericEditableURLCellEditMode.tsx @@ -7,6 +7,7 @@ import { import { useCurrentRowEntityId } from '@/ui/table/hooks/useCurrentEntityId'; import { useUpdateEntityField } from '@/ui/table/hooks/useUpdateEntityField'; import { tableEntityFieldFamilySelector } from '@/ui/table/states/tableEntityFieldFamilySelector'; +import { isURL } from '~/utils/is-url'; import { TextCellEdit } from './TextCellEdit'; @@ -30,6 +31,8 @@ export function GenericEditableURLCellEditMode({ viewField }: OwnProps) { function handleSubmit(newText: string) { if (newText === fieldValue) return; + if (!isURL(newText)) return; + setFieldValue(newText); if (currentRowEntityId && updateField) { diff --git a/front/src/utils/__tests__/is-url.test.ts b/front/src/utils/__tests__/is-url.test.ts new file mode 100644 index 0000000000..2ee1e54297 --- /dev/null +++ b/front/src/utils/__tests__/is-url.test.ts @@ -0,0 +1,35 @@ +import { isURL } from '~/utils/is-url'; + +describe('isURL', () => { + it(`should return false if null`, () => { + expect(isURL(null)).toBeFalsy(); + }); + + it(`should return false if undefined`, () => { + expect(isURL(undefined)).toBeFalsy(); + }); + + it(`should return true if string google`, () => { + expect(isURL('google')).toBeFalsy(); + }); + + it(`should return true if string google.com`, () => { + expect(isURL('google.com')).toBeTruthy(); + }); + + it(`should return true if string bbc.co.uk`, () => { + expect(isURL('bbc.co.uk')).toBeTruthy(); + }); + + it(`should return true if string web.io`, () => { + expect(isURL('web.io')).toBeTruthy(); + }); + + it(`should return true if string x.com`, () => { + expect(isURL('x.com')).toBeTruthy(); + }); + + it(`should return true if string 2.com`, () => { + expect(isURL('2.com')).toBeTruthy(); + }); +}); diff --git a/front/src/utils/is-url.ts b/front/src/utils/is-url.ts new file mode 100644 index 0000000000..b6c1f01d51 --- /dev/null +++ b/front/src/utils/is-url.ts @@ -0,0 +1,10 @@ +import { isDefined } from './isDefined'; + +export function isURL(url: string | undefined | null) { + return ( + isDefined(url) && + /^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/.test( + url, + ) + ); +} From 6b3a538c073732f2578a8b4e2cc2202265043dbc Mon Sep 17 00:00:00 2001 From: Emilien Chauvet Date: Thu, 10 Aug 2023 18:37:24 +0200 Subject: [PATCH 6/7] Feature/optmistically render table create & remove (#1156) * Add optimistic updates on company table * Add optimistic rendering for tables too * Fix schema --- front/src/generated/graphql.tsx | 55 ++++++++++--------- front/src/modules/companies/queries/update.ts | 18 +++--- .../TableActionBarButtonDeleteCompanies.tsx | 22 ++++++-- front/src/modules/people/queries/update.ts | 27 ++++----- .../TableActionBarButtonDeletePeople.tsx | 15 ++++- ...cEditableDoubleTextChipCellDisplayMode.tsx | 3 +- front/src/pages/companies/Companies.tsx | 30 +++++++--- front/src/pages/people/People.tsx | 24 +++++++- 8 files changed, 126 insertions(+), 68 deletions(-) diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 49a69b1622..9c53a8ec3b 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -2679,12 +2679,14 @@ export type UpdateOneCompanyMutationVariables = Exact<{ export type UpdateOneCompanyMutation = { __typename?: 'Mutation', updateOneCompany?: { __typename?: 'Company', address: string, createdAt: string, domainName: string, employees?: number | null, linkedinUrl?: string | null, id: string, name: string, accountOwner?: { __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null } | null } | null }; +export type InsertCompanyFragmentFragment = { __typename?: 'Company', domainName: string, address: string, id: string, name: string, createdAt: string }; + export type InsertOneCompanyMutationVariables = Exact<{ data: CompanyCreateInput; }>; -export type InsertOneCompanyMutation = { __typename?: 'Mutation', createOneCompany: { __typename?: 'Company', address: string, createdAt: string, domainName: string, linkedinUrl?: string | null, employees?: number | null, id: string, name: string } }; +export type InsertOneCompanyMutation = { __typename?: 'Mutation', createOneCompany: { __typename?: 'Company', domainName: string, address: string, id: string, name: string, createdAt: string } }; export type DeleteManyCompaniesMutationVariables = Exact<{ ids?: InputMaybe | Scalars['String']>; @@ -2766,12 +2768,14 @@ export type UpdateOnePersonMutationVariables = Exact<{ export type UpdateOnePersonMutation = { __typename?: 'Mutation', updateOnePerson?: { __typename?: 'Person', id: string, city?: string | null, email?: string | null, jobTitle?: string | null, linkedinUrl?: string | null, xUrl?: string | null, firstName?: string | null, lastName?: string | null, displayName: string, phone?: string | null, createdAt: string, company?: { __typename?: 'Company', domainName: string, name: string, id: string } | null } | null }; +export type InsertPersonFragmentFragment = { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, displayName: string, createdAt: string }; + export type InsertOnePersonMutationVariables = Exact<{ data: PersonCreateInput; }>; -export type InsertOnePersonMutation = { __typename?: 'Mutation', createOnePerson: { __typename?: 'Person', id: string, city?: string | null, email?: string | null, firstName?: string | null, lastName?: string | null, jobTitle?: string | null, linkedinUrl?: string | null, xUrl?: string | null, displayName: string, phone?: string | null, createdAt: string, company?: { __typename?: 'Company', domainName: string, name: string, id: string } | null } }; +export type InsertOnePersonMutation = { __typename?: 'Mutation', createOnePerson: { __typename?: 'Person', id: string, firstName?: string | null, lastName?: string | null, displayName: string, createdAt: string } }; export type DeleteManyPersonMutationVariables = Exact<{ ids?: InputMaybe | Scalars['String']>; @@ -3025,6 +3029,24 @@ export const ActivityUpdatePartsFragmentDoc = gql` } } `; +export const InsertCompanyFragmentFragmentDoc = gql` + fragment InsertCompanyFragment on Company { + domainName + address + id + name + createdAt +} + `; +export const InsertPersonFragmentFragmentDoc = gql` + fragment InsertPersonFragment on Person { + id + firstName + lastName + displayName + createdAt +} + `; export const CreateCommentDocument = gql` mutation CreateComment($commentId: String!, $commentText: String!, $authorId: String!, $activityId: String!, $createdAt: DateTime!) { createOneComment( @@ -4062,16 +4084,10 @@ export type UpdateOneCompanyMutationOptions = Apollo.BaseMutationOptions; /** @@ -4548,25 +4564,10 @@ export type UpdateOnePersonMutationOptions = Apollo.BaseMutationOptions; /** diff --git a/front/src/modules/companies/queries/update.ts b/front/src/modules/companies/queries/update.ts index 483baf33c1..fe9c2c7e70 100644 --- a/front/src/modules/companies/queries/update.ts +++ b/front/src/modules/companies/queries/update.ts @@ -24,16 +24,20 @@ export const UPDATE_ONE_COMPANY = gql` } `; +export const INSERT_COMPANY_FRAGMENT = gql` + fragment InsertCompanyFragment on Company { + domainName + address + id + name + createdAt + } +`; + export const INSERT_ONE_COMPANY = gql` mutation InsertOneCompany($data: CompanyCreateInput!) { createOneCompany(data: $data) { - address - createdAt - domainName - linkedinUrl - employees - id - name + ...InsertCompanyFragment } } `; diff --git a/front/src/modules/companies/table/components/TableActionBarButtonDeleteCompanies.tsx b/front/src/modules/companies/table/components/TableActionBarButtonDeleteCompanies.tsx index fc172760d9..63d51d09fc 100644 --- a/front/src/modules/companies/table/components/TableActionBarButtonDeleteCompanies.tsx +++ b/front/src/modules/companies/table/components/TableActionBarButtonDeleteCompanies.tsx @@ -1,12 +1,12 @@ import { getOperationName } from '@apollo/client/utilities'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; -import { GET_COMPANIES } from '@/companies/queries'; import { GET_PIPELINES } from '@/pipeline/queries'; import { IconTrash } from '@/ui/icon/index'; import { EntityTableActionBarButton } from '@/ui/table/action-bar/components/EntityTableActionBarButton'; import { useResetTableRowSelection } from '@/ui/table/hooks/useResetTableRowSelection'; import { selectedRowIdsSelector } from '@/ui/table/states/selectedRowIdsSelector'; +import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState'; import { useDeleteManyCompaniesMutation } from '~/generated/graphql'; export function TableActionBarButtonDeleteCompanies() { @@ -15,12 +15,11 @@ export function TableActionBarButtonDeleteCompanies() { const resetRowSelection = useResetTableRowSelection(); const [deleteCompanies] = useDeleteManyCompaniesMutation({ - refetchQueries: [ - getOperationName(GET_COMPANIES) ?? '', - getOperationName(GET_PIPELINES) ?? '', - ], + refetchQueries: [getOperationName(GET_PIPELINES) ?? ''], }); + const [tableRowIds, setTableRowIds] = useRecoilState(tableRowIdsState); + async function handleDeleteClick() { const rowIdsToDelete = selectedRowIds; @@ -30,6 +29,17 @@ export function TableActionBarButtonDeleteCompanies() { variables: { ids: rowIdsToDelete, }, + optimisticResponse: { + __typename: 'Mutation', + deleteManyCompany: { + count: rowIdsToDelete.length, + }, + }, + update: () => { + setTableRowIds( + tableRowIds.filter((id) => !rowIdsToDelete.includes(id)), + ); + }, }); } diff --git a/front/src/modules/people/queries/update.ts b/front/src/modules/people/queries/update.ts index 91c544f90c..7c3b00ebd5 100644 --- a/front/src/modules/people/queries/update.ts +++ b/front/src/modules/people/queries/update.ts @@ -26,25 +26,20 @@ export const UPDATE_ONE_PERSON = gql` } `; +export const INSERT_PERSON_FRAGMENT = gql` + fragment InsertPersonFragment on Person { + id + firstName + lastName + displayName + createdAt + } +`; + export const INSERT_ONE_PERSON = gql` mutation InsertOnePerson($data: PersonCreateInput!) { createOnePerson(data: $data) { - id - city - company { - domainName - name - id - } - email - firstName - lastName - jobTitle - linkedinUrl - xUrl - displayName - phone - createdAt + ...InsertPersonFragment } } `; diff --git a/front/src/modules/people/table/components/TableActionBarButtonDeletePeople.tsx b/front/src/modules/people/table/components/TableActionBarButtonDeletePeople.tsx index f431e96adc..f3bb51f6a7 100644 --- a/front/src/modules/people/table/components/TableActionBarButtonDeletePeople.tsx +++ b/front/src/modules/people/table/components/TableActionBarButtonDeletePeople.tsx @@ -1,15 +1,17 @@ import { getOperationName } from '@apollo/client/utilities'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { GET_PEOPLE } from '@/people/queries'; import { IconTrash } from '@/ui/icon/index'; import { EntityTableActionBarButton } from '@/ui/table/action-bar/components/EntityTableActionBarButton'; import { useResetTableRowSelection } from '@/ui/table/hooks/useResetTableRowSelection'; import { selectedRowIdsSelector } from '@/ui/table/states/selectedRowIdsSelector'; +import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState'; import { useDeleteManyPersonMutation } from '~/generated/graphql'; export function TableActionBarButtonDeletePeople() { const selectedRowIds = useRecoilValue(selectedRowIdsSelector); + const [tableRowIds, setTableRowIds] = useRecoilState(tableRowIdsState); const resetRowSelection = useResetTableRowSelection(); @@ -26,6 +28,17 @@ export function TableActionBarButtonDeletePeople() { variables: { ids: rowIdsToDelete, }, + optimisticResponse: { + __typename: 'Mutation', + deleteManyPerson: { + count: rowIdsToDelete.length, + }, + }, + update: () => { + setTableRowIds( + tableRowIds.filter((id) => !rowIdsToDelete.includes(id)), + ); + }, }); } diff --git a/front/src/modules/ui/table/editable-cell/type/components/GenericEditableDoubleTextChipCellDisplayMode.tsx b/front/src/modules/ui/table/editable-cell/type/components/GenericEditableDoubleTextChipCellDisplayMode.tsx index d4c61d051f..348e6b73ab 100644 --- a/front/src/modules/ui/table/editable-cell/type/components/GenericEditableDoubleTextChipCellDisplayMode.tsx +++ b/front/src/modules/ui/table/editable-cell/type/components/GenericEditableDoubleTextChipCellDisplayMode.tsx @@ -40,7 +40,8 @@ export function GenericEditableDoubleTextChipCellDisplayMode({ }), ); - const displayName = `${firstValue} ${secondValue}`; + const displayName = + firstValue || secondValue ? `${firstValue} ${secondValue}` : ' '; switch (viewField.metadata.entityType) { case Entity.Company: { diff --git a/front/src/pages/companies/Companies.tsx b/front/src/pages/companies/Companies.tsx index 77dd9e2f8d..79d04fd694 100644 --- a/front/src/pages/companies/Companies.tsx +++ b/front/src/pages/companies/Companies.tsx @@ -1,20 +1,21 @@ import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; +import { v4 } from 'uuid'; -import { GET_COMPANIES } from '@/companies/queries'; import { CompanyTable } from '@/companies/table/components/CompanyTable'; import { TableActionBarButtonCreateActivityCompany } from '@/companies/table/components/TableActionBarButtonCreateActivityCompany'; import { TableActionBarButtonDeleteCompanies } from '@/companies/table/components/TableActionBarButtonDeleteCompanies'; +import { SEARCH_COMPANY_QUERY } from '@/search/queries/search'; import { IconBuildingSkyscraper } from '@/ui/icon'; import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer'; import { EntityTableActionBar } from '@/ui/table/action-bar/components/EntityTableActionBar'; import { TableContext } from '@/ui/table/states/TableContext'; +import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; import { useInsertOneCompanyMutation } from '~/generated/graphql'; -import { SEARCH_COMPANY_QUERY } from '../../modules/search/queries/search'; - const StyledTableContainer = styled.div` display: flex; width: 100%; @@ -22,20 +23,35 @@ const StyledTableContainer = styled.div` export function Companies() { const [insertCompany] = useInsertOneCompanyMutation(); + const [tableRowIds, setTableRowIds] = useRecoilState(tableRowIdsState); async function handleAddButtonClick() { + const newCompanyId: string = v4(); await insertCompany({ variables: { data: { + id: newCompanyId, name: '', domainName: '', address: '', }, }, - refetchQueries: [ - getOperationName(GET_COMPANIES) ?? '', - getOperationName(SEARCH_COMPANY_QUERY) ?? '', - ], + optimisticResponse: { + __typename: 'Mutation', + createOneCompany: { + __typename: 'Company', + id: newCompanyId, + name: '', + domainName: '', + address: '', + createdAt: '', + }, + }, + update: (cache, { data }) => { + data?.createOneCompany.id && + setTableRowIds([data?.createOneCompany.id, ...tableRowIds]); + }, + refetchQueries: [getOperationName(SEARCH_COMPANY_QUERY) ?? ''], }); } diff --git a/front/src/pages/people/People.tsx b/front/src/pages/people/People.tsx index edbf396dc0..4ed08ad2ca 100644 --- a/front/src/pages/people/People.tsx +++ b/front/src/pages/people/People.tsx @@ -1,8 +1,8 @@ -import { getOperationName } from '@apollo/client/utilities'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; +import { v4 } from 'uuid'; -import { GET_PEOPLE } from '@/people/queries'; import { PeopleTable } from '@/people/table/components/PeopleTable'; import { TableActionBarButtonCreateActivityPeople } from '@/people/table/components/TableActionBarButtonCreateActivityPeople'; import { TableActionBarButtonDeletePeople } from '@/people/table/components/TableActionBarButtonDeletePeople'; @@ -10,6 +10,7 @@ import { IconUser } from '@/ui/icon'; import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer'; import { EntityTableActionBar } from '@/ui/table/action-bar/components/EntityTableActionBar'; import { TableContext } from '@/ui/table/states/TableContext'; +import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; import { useInsertOnePersonMutation } from '~/generated/graphql'; @@ -20,16 +21,33 @@ const StyledTableContainer = styled.div` export function People() { const [insertOnePerson] = useInsertOnePersonMutation(); + const [tableRowIds, setTableRowIds] = useRecoilState(tableRowIdsState); async function handleAddButtonClick() { + const newPersonId: string = v4(); await insertOnePerson({ variables: { data: { + id: newPersonId, firstName: '', lastName: '', }, }, - refetchQueries: [getOperationName(GET_PEOPLE) ?? ''], + optimisticResponse: { + __typename: 'Mutation', + createOnePerson: { + __typename: 'Person', + id: newPersonId, + firstName: '', + lastName: '', + displayName: '', + createdAt: '', + }, + }, + update: (cache, { data }) => { + data?.createOnePerson?.id && + setTableRowIds([data?.createOnePerson.id, ...tableRowIds]); + }, }); } From 80a562d90d1d354c580351a2c94d32aa024b139e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Thu, 10 Aug 2023 19:10:02 +0200 Subject: [PATCH 7/7] feat: persist view sorts (#1154) Closes #1122 --- front/src/generated/graphql.tsx | 233 ++++++++++++++++++ .../queries/__tests__/select.test.ts | 3 +- .../table/components/CompanyTable.tsx | 27 +- .../people/table/components/PeopleTable.tsx | 28 ++- front/src/modules/ui/filter-n-sort/helpers.ts | 33 +-- .../filter-n-sort/states/sortScopedState.ts | 26 +- .../ui/filter-n-sort/types/interface.ts | 2 +- .../ui/table/components/EntityTableHeader.tsx | 25 +- .../components/GenericEntityTableData.tsx | 4 +- .../{useLoadView.ts => useLoadViewFields.ts} | 28 ++- .../table-header/components/TableHeader.tsx | 23 +- .../hooks/useRecoilScopedValue.ts | 4 +- front/src/modules/views/hooks/useViewSorts.ts | 152 ++++++++++++ front/src/modules/views/queries/create.ts | 8 + front/src/modules/views/queries/delete.ts | 9 + front/src/modules/views/queries/select.ts | 10 + front/src/modules/views/queries/update.ts | 13 + .../views/states/currentViewIdState.ts | 6 + front/src/pages/companies/companies-sorts.tsx | 4 +- front/src/pages/people/people-sorts.tsx | 16 +- server/src/ability/ability.factory.ts | 13 +- server/src/ability/ability.module.ts | 16 ++ .../handlers/view-sort.ability-handler.ts | 122 +++++++++ .../ability/handlers/view.ability-handler.ts | 79 ++++++ .../view/resolvers/view-sort.resolver.spec.ts | 32 +++ .../core/view/resolvers/view-sort.resolver.ts | 102 ++++++++ .../view/services/view-sort.service.spec.ts | 28 +++ .../core/view/services/view-sort.service.ts | 39 +++ server/src/core/view/view.module.ts | 9 +- 29 files changed, 991 insertions(+), 103 deletions(-) rename front/src/modules/ui/table/hooks/{useLoadView.ts => useLoadViewFields.ts} (82%) create mode 100644 front/src/modules/views/hooks/useViewSorts.ts create mode 100644 front/src/modules/views/queries/delete.ts create mode 100644 front/src/modules/views/states/currentViewIdState.ts create mode 100644 server/src/ability/handlers/view-sort.ability-handler.ts create mode 100644 server/src/ability/handlers/view.ability-handler.ts create mode 100644 server/src/core/view/resolvers/view-sort.resolver.spec.ts create mode 100644 server/src/core/view/resolvers/view-sort.resolver.ts create mode 100644 server/src/core/view/services/view-sort.service.spec.ts create mode 100644 server/src/core/view/services/view-sort.service.ts diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 9c53a8ec3b..b5019d5fd2 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -916,6 +916,7 @@ export type Mutation = { challenge: LoginToken; createEvent: Analytics; createManyViewField: AffectedRows; + createManyViewSort: AffectedRows; createOneActivity: Activity; createOneComment: Comment; createOneCompany: Company; @@ -927,6 +928,7 @@ export type Mutation = { deleteManyCompany: AffectedRows; deleteManyPerson: AffectedRows; deleteManyPipelineProgress: AffectedRows; + deleteManyViewSort: AffectedRows; deleteUserAccount: User; deleteWorkspaceMember: WorkspaceMember; impersonate: Verify; @@ -938,6 +940,7 @@ export type Mutation = { updateOnePipelineProgress?: Maybe; updateOnePipelineStage?: Maybe; updateOneViewField: ViewField; + updateOneViewSort: ViewSort; updateUser: User; updateWorkspace: Workspace; uploadAttachment: Scalars['String']; @@ -973,6 +976,12 @@ export type MutationCreateManyViewFieldArgs = { }; +export type MutationCreateManyViewSortArgs = { + data: Array; + skipDuplicates?: InputMaybe; +}; + + export type MutationCreateOneActivityArgs = { data: ActivityCreateInput; }; @@ -1023,6 +1032,11 @@ export type MutationDeleteManyPipelineProgressArgs = { }; +export type MutationDeleteManyViewSortArgs = { + where?: InputMaybe; +}; + + export type MutationDeleteWorkspaceMemberArgs = { where: WorkspaceMemberWhereUniqueInput; }; @@ -1081,6 +1095,12 @@ export type MutationUpdateOneViewFieldArgs = { }; +export type MutationUpdateOneViewSortArgs = { + data: ViewSortUpdateInput; + where: ViewSortWhereUniqueInput; +}; + + export type MutationUpdateUserArgs = { data: UserUpdateInput; where: UserWhereUniqueInput; @@ -1794,6 +1814,7 @@ export type Query = { findManyPipelineStage: Array; findManyUser: Array; findManyViewField: Array; + findManyViewSort: Array; findManyWorkspaceMember: Array; findUniqueCompany: Company; findUniquePerson: Person; @@ -1891,6 +1912,16 @@ export type QueryFindManyViewFieldArgs = { }; +export type QueryFindManyViewSortArgs = { + cursor?: InputMaybe; + distinct?: InputMaybe>; + orderBy?: InputMaybe>; + skip?: InputMaybe; + take?: InputMaybe; + where?: InputMaybe; +}; + + export type QueryFindManyWorkspaceMemberArgs = { cursor?: InputMaybe; distinct?: InputMaybe>; @@ -2331,6 +2362,13 @@ export type ViewSort = { viewId: Scalars['String']; }; +export type ViewSortCreateManyInput = { + direction: ViewSortDirection; + key: Scalars['String']; + name: Scalars['String']; + viewId: Scalars['String']; +}; + export enum ViewSortDirection { Asc = 'asc', Desc = 'desc' @@ -2346,6 +2384,29 @@ export type ViewSortOrderByRelationAggregateInput = { _count?: InputMaybe; }; +export type ViewSortOrderByWithRelationInput = { + direction?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; + view?: InputMaybe; + viewId?: InputMaybe; +}; + +export enum ViewSortScalarFieldEnum { + Direction = 'direction', + Key = 'key', + Name = 'name', + ViewId = 'viewId', + WorkspaceId = 'workspaceId' +} + +export type ViewSortUpdateInput = { + direction?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; + view?: InputMaybe; +}; + export type ViewSortUpdateManyWithoutWorkspaceNestedInput = { connect?: InputMaybe>; disconnect?: InputMaybe>; @@ -2383,6 +2444,10 @@ export type ViewUpdateManyWithoutWorkspaceNestedInput = { set?: InputMaybe>; }; +export type ViewUpdateOneRequiredWithoutSortsNestedInput = { + connect?: InputMaybe; +}; + export type ViewUpdateOneWithoutFieldsNestedInput = { connect?: InputMaybe; disconnect?: InputMaybe; @@ -2954,6 +3019,20 @@ export type CreateViewFieldsMutationVariables = Exact<{ export type CreateViewFieldsMutation = { __typename?: 'Mutation', createManyViewField: { __typename?: 'AffectedRows', count: number } }; +export type CreateViewSortsMutationVariables = Exact<{ + data: Array | ViewSortCreateManyInput; +}>; + + +export type CreateViewSortsMutation = { __typename?: 'Mutation', createManyViewSort: { __typename?: 'AffectedRows', count: number } }; + +export type DeleteViewSortsMutationVariables = Exact<{ + where: ViewSortWhereInput; +}>; + + +export type DeleteViewSortsMutation = { __typename?: 'Mutation', deleteManyViewSort: { __typename?: 'AffectedRows', count: number } }; + export type GetViewFieldsQueryVariables = Exact<{ where?: InputMaybe; orderBy?: InputMaybe | ViewFieldOrderByWithRelationInput>; @@ -2962,6 +3041,13 @@ export type GetViewFieldsQueryVariables = Exact<{ export type GetViewFieldsQuery = { __typename?: 'Query', viewFields: Array<{ __typename?: 'ViewField', id: string, fieldName: string, isVisible: boolean, sizeInPx: number, index: number }> }; +export type GetViewSortsQueryVariables = Exact<{ + where?: InputMaybe; +}>; + + +export type GetViewSortsQuery = { __typename?: 'Query', viewSorts: Array<{ __typename?: 'ViewSort', direction: ViewSortDirection, key: string, name: string }> }; + export type UpdateViewFieldMutationVariables = Exact<{ data: ViewFieldUpdateInput; where: ViewFieldWhereUniqueInput; @@ -2970,6 +3056,14 @@ export type UpdateViewFieldMutationVariables = Exact<{ export type UpdateViewFieldMutation = { __typename?: 'Mutation', updateOneViewField: { __typename?: 'ViewField', id: string, fieldName: string, isVisible: boolean, sizeInPx: number, index: number } }; +export type UpdateViewSortMutationVariables = Exact<{ + data: ViewSortUpdateInput; + where: ViewSortWhereUniqueInput; +}>; + + +export type UpdateViewSortMutation = { __typename?: 'Mutation', viewSort: { __typename?: 'ViewSort', direction: ViewSortDirection, key: string, name: string } }; + export type GetWorkspaceMembersQueryVariables = Exact<{ [key: string]: never; }>; @@ -5523,6 +5617,72 @@ export function useCreateViewFieldsMutation(baseOptions?: Apollo.MutationHookOpt export type CreateViewFieldsMutationHookResult = ReturnType; export type CreateViewFieldsMutationResult = Apollo.MutationResult; export type CreateViewFieldsMutationOptions = Apollo.BaseMutationOptions; +export const CreateViewSortsDocument = gql` + mutation CreateViewSorts($data: [ViewSortCreateManyInput!]!) { + createManyViewSort(data: $data) { + count + } +} + `; +export type CreateViewSortsMutationFn = Apollo.MutationFunction; + +/** + * __useCreateViewSortsMutation__ + * + * To run a mutation, you first call `useCreateViewSortsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateViewSortsMutation` 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 [createViewSortsMutation, { data, loading, error }] = useCreateViewSortsMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useCreateViewSortsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateViewSortsDocument, options); + } +export type CreateViewSortsMutationHookResult = ReturnType; +export type CreateViewSortsMutationResult = Apollo.MutationResult; +export type CreateViewSortsMutationOptions = Apollo.BaseMutationOptions; +export const DeleteViewSortsDocument = gql` + mutation DeleteViewSorts($where: ViewSortWhereInput!) { + deleteManyViewSort(where: $where) { + count + } +} + `; +export type DeleteViewSortsMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteViewSortsMutation__ + * + * To run a mutation, you first call `useDeleteViewSortsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteViewSortsMutation` 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 [deleteViewSortsMutation, { data, loading, error }] = useDeleteViewSortsMutation({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useDeleteViewSortsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteViewSortsDocument, options); + } +export type DeleteViewSortsMutationHookResult = ReturnType; +export type DeleteViewSortsMutationResult = Apollo.MutationResult; +export type DeleteViewSortsMutationOptions = Apollo.BaseMutationOptions; export const GetViewFieldsDocument = gql` query GetViewFields($where: ViewFieldWhereInput, $orderBy: [ViewFieldOrderByWithRelationInput!]) { viewFields: findManyViewField(where: $where, orderBy: $orderBy) { @@ -5563,6 +5723,43 @@ export function useGetViewFieldsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti export type GetViewFieldsQueryHookResult = ReturnType; export type GetViewFieldsLazyQueryHookResult = ReturnType; export type GetViewFieldsQueryResult = Apollo.QueryResult; +export const GetViewSortsDocument = gql` + query GetViewSorts($where: ViewSortWhereInput) { + viewSorts: findManyViewSort(where: $where) { + direction + key + name + } +} + `; + +/** + * __useGetViewSortsQuery__ + * + * To run a query within a React component, call `useGetViewSortsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetViewSortsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetViewSortsQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useGetViewSortsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetViewSortsDocument, options); + } +export function useGetViewSortsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetViewSortsDocument, options); + } +export type GetViewSortsQueryHookResult = ReturnType; +export type GetViewSortsLazyQueryHookResult = ReturnType; +export type GetViewSortsQueryResult = Apollo.QueryResult; export const UpdateViewFieldDocument = gql` mutation UpdateViewField($data: ViewFieldUpdateInput!, $where: ViewFieldWhereUniqueInput!) { updateOneViewField(data: $data, where: $where) { @@ -5601,6 +5798,42 @@ export function useUpdateViewFieldMutation(baseOptions?: Apollo.MutationHookOpti export type UpdateViewFieldMutationHookResult = ReturnType; export type UpdateViewFieldMutationResult = Apollo.MutationResult; export type UpdateViewFieldMutationOptions = Apollo.BaseMutationOptions; +export const UpdateViewSortDocument = gql` + mutation UpdateViewSort($data: ViewSortUpdateInput!, $where: ViewSortWhereUniqueInput!) { + viewSort: updateOneViewSort(data: $data, where: $where) { + direction + key + name + } +} + `; +export type UpdateViewSortMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateViewSortMutation__ + * + * To run a mutation, you first call `useUpdateViewSortMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateViewSortMutation` 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 [updateViewSortMutation, { data, loading, error }] = useUpdateViewSortMutation({ + * variables: { + * data: // value for 'data' + * where: // value for 'where' + * }, + * }); + */ +export function useUpdateViewSortMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateViewSortDocument, options); + } +export type UpdateViewSortMutationHookResult = ReturnType; +export type UpdateViewSortMutationResult = Apollo.MutationResult; +export type UpdateViewSortMutationOptions = Apollo.BaseMutationOptions; export const GetWorkspaceMembersDocument = gql` query GetWorkspaceMembers { workspaceMembers: findManyWorkspaceMember { diff --git a/front/src/modules/companies/queries/__tests__/select.test.ts b/front/src/modules/companies/queries/__tests__/select.test.ts index 80e292f7b5..bb0ddb0ba6 100644 --- a/front/src/modules/companies/queries/__tests__/select.test.ts +++ b/front/src/modules/companies/queries/__tests__/select.test.ts @@ -5,12 +5,11 @@ import { CompaniesSelectedSortType } from '../select'; describe('reduceSortsToOrderBy', () => { it('should return an array of objects with the id as key and the order as value', () => { const sorts = [ - { key: 'name', label: 'name', order: 'asc', _type: 'default_sort' }, + { key: 'name', label: 'name', order: 'asc' }, { key: 'domainName', label: 'domainName', order: 'desc', - _type: 'default_sort', }, ] satisfies CompaniesSelectedSortType[]; const result = reduceSortsToOrderBy(sorts); diff --git a/front/src/modules/companies/table/components/CompanyTable.tsx b/front/src/modules/companies/table/components/CompanyTable.tsx index df38b6e2f0..cd5ba3d7d3 100644 --- a/front/src/modules/companies/table/components/CompanyTable.tsx +++ b/front/src/modules/companies/table/components/CompanyTable.tsx @@ -1,30 +1,33 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import { companyViewFields } from '@/companies/constants/companyViewFields'; -import { CompaniesSelectedSortType, defaultOrderBy } from '@/companies/queries'; -import { reduceSortsToOrderBy } from '@/ui/filter-n-sort/helpers'; import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState'; +import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState'; import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause'; import { IconList } from '@/ui/icon'; import { EntityTable } from '@/ui/table/components/EntityTable'; import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData'; import { TableContext } from '@/ui/table/states/TableContext'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { useViewSorts } from '@/views/hooks/useViewSorts'; +import { currentViewIdState } from '@/views/states/currentViewIdState'; import { - CompanyOrderByWithRelationInput, useGetCompaniesQuery, useUpdateOneCompanyMutation, } from '~/generated/graphql'; import { companiesFilters } from '~/pages/companies/companies-filters'; import { availableSorts } from '~/pages/companies/companies-sorts'; -export function CompanyTable() { - const [orderBy, setOrderBy] = - useState(defaultOrderBy); +import { defaultOrderBy } from '../../queries'; - const updateSorts = useCallback((sorts: Array) => { - setOrderBy(sorts.length ? reduceSortsToOrderBy(sorts) : defaultOrderBy); - }, []); +export function CompanyTable() { + const currentViewId = useRecoilValue(currentViewIdState); + const orderBy = useRecoilScopedValue(sortsOrderByScopedState, TableContext); + const { updateSorts } = useViewSorts({ + availableSorts, + Context: TableContext, + }); const filters = useRecoilScopedValue(filtersScopedState, TableContext); @@ -38,7 +41,7 @@ export function CompanyTable() { objectName="company" getRequestResultKey="companies" useGetRequest={useGetCompaniesQuery} - orderBy={orderBy} + orderBy={orderBy.length ? orderBy : defaultOrderBy} whereFilters={whereFilters} viewFieldDefinitions={companyViewFields} filterDefinitionArray={companiesFilters} @@ -47,7 +50,7 @@ export function CompanyTable() { viewName="All Companies" viewIcon={} availableSorts={availableSorts} - onSortsUpdate={updateSorts} + onSortsUpdate={currentViewId ? updateSorts : undefined} useUpdateEntityMutation={useUpdateOneCompanyMutation} /> diff --git a/front/src/modules/people/table/components/PeopleTable.tsx b/front/src/modules/people/table/components/PeopleTable.tsx index ab80fabd43..0e4bd2ba7c 100644 --- a/front/src/modules/people/table/components/PeopleTable.tsx +++ b/front/src/modules/people/table/components/PeopleTable.tsx @@ -1,31 +1,33 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; -import { defaultOrderBy } from '@/companies/queries'; import { peopleViewFields } from '@/people/constants/peopleViewFields'; -import { PeopleSelectedSortType } from '@/people/queries'; -import { reduceSortsToOrderBy } from '@/ui/filter-n-sort/helpers'; import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState'; +import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState'; import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause'; import { IconList } from '@/ui/icon'; import { EntityTable } from '@/ui/table/components/EntityTable'; import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData'; import { TableContext } from '@/ui/table/states/TableContext'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { useViewSorts } from '@/views/hooks/useViewSorts'; +import { currentViewIdState } from '@/views/states/currentViewIdState'; import { - PersonOrderByWithRelationInput, useGetPeopleQuery, useUpdateOnePersonMutation, } from '~/generated/graphql'; import { peopleFilters } from '~/pages/people/people-filters'; import { availableSorts } from '~/pages/people/people-sorts'; -export function PeopleTable() { - const [orderBy, setOrderBy] = - useState(defaultOrderBy); +import { defaultOrderBy } from '../../queries'; - const updateSorts = useCallback((sorts: Array) => { - setOrderBy(sorts.length ? reduceSortsToOrderBy(sorts) : defaultOrderBy); - }, []); +export function PeopleTable() { + const currentViewId = useRecoilValue(currentViewIdState); + const orderBy = useRecoilScopedValue(sortsOrderByScopedState, TableContext); + const { updateSorts } = useViewSorts({ + availableSorts, + Context: TableContext, + }); const filters = useRecoilScopedValue(filtersScopedState, TableContext); @@ -39,7 +41,7 @@ export function PeopleTable() { objectName="person" getRequestResultKey="people" useGetRequest={useGetPeopleQuery} - orderBy={orderBy} + orderBy={orderBy.length ? orderBy : defaultOrderBy} whereFilters={whereFilters} viewFieldDefinitions={peopleViewFields} filterDefinitionArray={peopleFilters} @@ -48,7 +50,7 @@ export function PeopleTable() { viewName="All People" viewIcon={} availableSorts={availableSorts} - onSortsUpdate={updateSorts} + onSortsUpdate={currentViewId ? updateSorts : undefined} useUpdateEntityMutation={useUpdateOnePersonMutation} /> diff --git a/front/src/modules/ui/filter-n-sort/helpers.ts b/front/src/modules/ui/filter-n-sort/helpers.ts index 818a231348..596e4d2f4e 100644 --- a/front/src/modules/ui/filter-n-sort/helpers.ts +++ b/front/src/modules/ui/filter-n-sort/helpers.ts @@ -2,27 +2,16 @@ import { SortOrder as Order_By } from '~/generated/graphql'; import { SelectedSortType } from './types/interface'; -const mapOrderToOrder_By = (order: string) => { - if (order === 'asc') return Order_By.Asc; - return Order_By.Desc; -}; - -export const defaultOrderByTemplateFactory = - (key: string) => (order: string) => ({ - [key]: order, - }); - export const reduceSortsToOrderBy = ( - sorts: Array>, -): OrderByTemplate[] => { - const mappedSorts = sorts.map((sort) => { - if (sort.orderByTemplates) { - return sort.orderByTemplates?.map((orderByTemplate) => - orderByTemplate(mapOrderToOrder_By(sort.order)), + sorts: SelectedSortType[], +): OrderByTemplate[] => + sorts + .map((sort) => { + const order = sort.order === 'asc' ? Order_By.Asc : Order_By.Desc; + return ( + sort.orderByTemplate?.(order) || [ + { [sort.key]: order } as OrderByTemplate, + ] ); - } - - return defaultOrderByTemplateFactory(sort.key as string)(sort.order); - }); - return mappedSorts.flat() as OrderByTemplate[]; -}; + }) + .flat(); diff --git a/front/src/modules/ui/filter-n-sort/states/sortScopedState.ts b/front/src/modules/ui/filter-n-sort/states/sortScopedState.ts index 27b11d816e..7d9021a247 100644 --- a/front/src/modules/ui/filter-n-sort/states/sortScopedState.ts +++ b/front/src/modules/ui/filter-n-sort/states/sortScopedState.ts @@ -1,8 +1,28 @@ -import { atomFamily } from 'recoil'; +import { atomFamily, selectorFamily } from 'recoil'; -import { Filter } from '../types/Filter'; +import { reduceSortsToOrderBy } from '../helpers'; +import { SelectedSortType } from '../types/interface'; -export const sortScopedState = atomFamily({ +export const sortScopedState = atomFamily[], string>({ key: 'sortScopedState', default: [], }); + +export const sortsByKeyScopedState = selectorFamily({ + key: 'sortsByKeyScopedState', + get: + (param: string) => + ({ get }) => + get(sortScopedState(param)).reduce>>( + (result, sort) => ({ ...result, [sort.key]: sort }), + {}, + ), +}); + +export const sortsOrderByScopedState = selectorFamily({ + key: 'sortsOrderByScopedState', + get: + (param: string) => + ({ get }) => + reduceSortsToOrderBy(get(sortScopedState(param))), +}); diff --git a/front/src/modules/ui/filter-n-sort/types/interface.ts b/front/src/modules/ui/filter-n-sort/types/interface.ts index 1610e1b443..0cba99a243 100644 --- a/front/src/modules/ui/filter-n-sort/types/interface.ts +++ b/front/src/modules/ui/filter-n-sort/types/interface.ts @@ -6,7 +6,7 @@ export type SortType = { label: string; key: string; icon?: ReactNode; - orderByTemplates?: Array<(order: Order_By) => OrderByTemplate>; + orderByTemplate?: (order: Order_By) => OrderByTemplate[]; }; export type SelectedSortType = SortType & { diff --git a/front/src/modules/ui/table/components/EntityTableHeader.tsx b/front/src/modules/ui/table/components/EntityTableHeader.tsx index c4bfd99c6b..e999449cc5 100644 --- a/front/src/modules/ui/table/components/EntityTableHeader.tsx +++ b/front/src/modules/ui/table/components/EntityTableHeader.tsx @@ -5,19 +5,20 @@ import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; import { IconButton } from '@/ui/button/components/IconButton'; +import type { + ViewFieldDefinition, + ViewFieldMetadata, +} from '@/ui/editable-field/types/ViewField'; import { IconPlus } from '@/ui/icon'; import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer'; import { GET_VIEW_FIELDS } from '@/views/queries/select'; +import { currentViewIdState } from '@/views/states/currentViewIdState'; import { useCreateViewFieldMutation, useUpdateViewFieldMutation, } from '~/generated/graphql'; -import type { - ViewFieldDefinition, - ViewFieldMetadata, -} from '../../editable-field/types/ViewField'; -import { toViewFieldInput } from '../hooks/useLoadView'; +import { toViewFieldInput } from '../hooks/useLoadViewFields'; import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState'; import { addableViewFieldDefinitionsState, @@ -89,6 +90,7 @@ export function EntityTableHeader() { const theme = useTheme(); const [{ objectName }, setViewFieldsState] = useRecoilState(viewFieldsState); + const currentViewId = useRecoilValue(currentViewIdState); const viewFields = useRecoilValue(visibleViewFieldsState); const columnWidths = useRecoilValue(columnWidthByViewFieldIdState); const addableViewFieldDefinitions = useRecoilValue( @@ -176,15 +178,18 @@ export function EntityTableHeader() { createViewFieldMutation({ variables: { - data: toViewFieldInput(objectName, { - ...viewFieldDefinition, - columnOrder: viewFields.length + 1, - }), + data: { + ...toViewFieldInput(objectName, { + ...viewFieldDefinition, + columnOrder: viewFields.length + 1, + }), + view: { connect: { id: currentViewId } }, + }, }, refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''], }); }, - [createViewFieldMutation, objectName, viewFields.length], + [createViewFieldMutation, currentViewId, objectName, viewFields.length], ); return ( diff --git a/front/src/modules/ui/table/components/GenericEntityTableData.tsx b/front/src/modules/ui/table/components/GenericEntityTableData.tsx index bed37bc818..f284571479 100644 --- a/front/src/modules/ui/table/components/GenericEntityTableData.tsx +++ b/front/src/modules/ui/table/components/GenericEntityTableData.tsx @@ -6,7 +6,7 @@ import { import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition'; import { useSetEntityTableData } from '@/ui/table/hooks/useSetEntityTableData'; -import { useLoadView } from '../hooks/useLoadView'; +import { useLoadViewFields } from '../hooks/useLoadViewFields'; export function GenericEntityTableData({ objectName, @@ -27,7 +27,7 @@ export function GenericEntityTableData({ }) { const setEntityTableData = useSetEntityTableData(); - useLoadView({ objectName, viewFieldDefinitions }); + useLoadViewFields({ objectName, viewFieldDefinitions }); useGetRequest({ variables: { orderBy, where: whereFilters }, diff --git a/front/src/modules/ui/table/hooks/useLoadView.ts b/front/src/modules/ui/table/hooks/useLoadViewFields.ts similarity index 82% rename from front/src/modules/ui/table/hooks/useLoadView.ts rename to front/src/modules/ui/table/hooks/useLoadViewFields.ts index 801531aef4..f7cbb56405 100644 --- a/front/src/modules/ui/table/hooks/useLoadView.ts +++ b/front/src/modules/ui/table/hooks/useLoadViewFields.ts @@ -1,18 +1,19 @@ import { getOperationName } from '@apollo/client/utilities'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import type { + ViewFieldDefinition, + ViewFieldMetadata, + ViewFieldTextMetadata, +} from '@/ui/editable-field/types/ViewField'; import { GET_VIEW_FIELDS } from '@/views/queries/select'; +import { currentViewIdState } from '@/views/states/currentViewIdState'; import { SortOrder, useCreateViewFieldsMutation, useGetViewFieldsQuery, } from '~/generated/graphql'; -import type { - ViewFieldDefinition, - ViewFieldMetadata, - ViewFieldTextMetadata, -} from '../../editable-field/types/ViewField'; import { entityTableDimensionsState } from '../states/entityTableDimensionsState'; import { viewFieldsState } from '../states/viewFieldsState'; @@ -33,13 +34,14 @@ export const toViewFieldInput = ( sizeInPx: viewFieldDefinition.columnSize, }); -export const useLoadView = ({ +export const useLoadViewFields = ({ objectName, viewFieldDefinitions, }: { objectName: 'company' | 'person'; viewFieldDefinitions: ViewFieldDefinition[]; }) => { + const currentViewId = useRecoilValue(currentViewIdState); const setEntityTableDimensions = useSetRecoilState( entityTableDimensionsState, ); @@ -50,7 +52,10 @@ export const useLoadView = ({ useGetViewFieldsQuery({ variables: { orderBy: { index: SortOrder.Asc }, - where: { objectName: { equals: objectName } }, + where: { + objectName: { equals: objectName }, + viewId: { equals: currentViewId ?? null }, + }, }, onCompleted: (data) => { if (data.viewFields.length) { @@ -79,9 +84,10 @@ export const useLoadView = ({ // Populate if empty createViewFieldsMutation({ variables: { - data: viewFieldDefinitions.map((viewFieldDefinition) => - toViewFieldInput(objectName, viewFieldDefinition), - ), + data: viewFieldDefinitions.map((viewFieldDefinition) => ({ + ...toViewFieldInput(objectName, viewFieldDefinition), + viewId: currentViewId, + })), }, refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''], }); 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 1934794110..c406d8b806 100644 --- a/front/src/modules/ui/table/table-header/components/TableHeader.tsx +++ b/front/src/modules/ui/table/table-header/components/TableHeader.tsx @@ -1,12 +1,14 @@ -import { ReactNode, useCallback, useState } from 'react'; +import { ReactNode, useCallback } from 'react'; import styled from '@emotion/styled'; import { FilterDropdownButton } from '@/ui/filter-n-sort/components/FilterDropdownButton'; import SortAndFilterBar from '@/ui/filter-n-sort/components/SortAndFilterBar'; import { SortDropdownButton } from '@/ui/filter-n-sort/components/SortDropdownButton'; +import { sortScopedState } from '@/ui/filter-n-sort/states/sortScopedState'; import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope'; import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface'; import { TopBar } from '@/ui/top-bar/TopBar'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { OptionsDropdownButton } from '@/views/components/OptionsDropdownButton'; import { TableContext } from '../../states/TableContext'; @@ -34,26 +36,26 @@ export function TableHeader({ availableSorts, onSortsUpdate, }: OwnProps) { - const [sorts, innerSetSorts] = useState>>( - [], + const [sorts, setSorts] = useRecoilScopedState[]>( + sortScopedState, + TableContext, ); + const handleSortsUpdate = onSortsUpdate ?? setSorts; const sortSelect = useCallback( (newSort: SelectedSortType) => { const newSorts = updateSortOrFilterByKey(sorts, newSort); - innerSetSorts(newSorts); - onSortsUpdate && onSortsUpdate(newSorts); + handleSortsUpdate(newSorts); }, - [onSortsUpdate, sorts], + [handleSortsUpdate, sorts], ); const sortUnselect = useCallback( (sortKey: string) => { const newSorts = sorts.filter((sort) => sort.key !== sortKey); - innerSetSorts(newSorts); - onSortsUpdate && onSortsUpdate(newSorts); + handleSortsUpdate(newSorts); }, - [onSortsUpdate, sorts], + [handleSortsUpdate, sorts], ); return ( @@ -88,8 +90,7 @@ export function TableHeader({ sorts={sorts} onRemoveSort={sortUnselect} onCancelClick={() => { - innerSetSorts([]); - onSortsUpdate && onSortsUpdate([]); + handleSortsUpdate([]); }} /> } diff --git a/front/src/modules/ui/utilities/recoil-scope/hooks/useRecoilScopedValue.ts b/front/src/modules/ui/utilities/recoil-scope/hooks/useRecoilScopedValue.ts index 1ef6e8ff6b..a3f65ef8f1 100644 --- a/front/src/modules/ui/utilities/recoil-scope/hooks/useRecoilScopedValue.ts +++ b/front/src/modules/ui/utilities/recoil-scope/hooks/useRecoilScopedValue.ts @@ -1,10 +1,10 @@ import { Context, useContext } from 'react'; -import { RecoilState, useRecoilValue } from 'recoil'; +import { RecoilState, RecoilValueReadOnly, useRecoilValue } from 'recoil'; import { RecoilScopeContext } from '../states/RecoilScopeContext'; export function useRecoilScopedValue( - recoilState: (param: string) => RecoilState, + recoilState: (param: string) => RecoilState | RecoilValueReadOnly, SpecificContext?: Context, ) { const recoilScopeId = useContext(SpecificContext ?? RecoilScopeContext); diff --git a/front/src/modules/views/hooks/useViewSorts.ts b/front/src/modules/views/hooks/useViewSorts.ts new file mode 100644 index 0000000000..81ec89b003 --- /dev/null +++ b/front/src/modules/views/hooks/useViewSorts.ts @@ -0,0 +1,152 @@ +import { Context, useCallback } from 'react'; +import { getOperationName } from '@apollo/client/utilities'; +import { useRecoilValue } from 'recoil'; + +import { + sortsByKeyScopedState, + sortScopedState, +} from '@/ui/filter-n-sort/states/sortScopedState'; +import type { + SelectedSortType, + SortType, +} from '@/ui/filter-n-sort/types/interface'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { currentViewIdState } from '@/views/states/currentViewIdState'; +import { + useCreateViewSortsMutation, + useDeleteViewSortsMutation, + useGetViewSortsQuery, + useUpdateViewSortMutation, + ViewSortDirection, +} from '~/generated/graphql'; + +import { GET_VIEW_SORTS } from '../queries/select'; + +export const useViewSorts = ({ + availableSorts, + Context, +}: { + availableSorts: SortType[]; + Context?: Context; +}) => { + const currentViewId = useRecoilValue(currentViewIdState); + const [, setSorts] = useRecoilScopedState(sortScopedState, Context); + const sortsByKey = useRecoilScopedValue(sortsByKeyScopedState, Context); + + useGetViewSortsQuery({ + skip: !currentViewId, + variables: { + where: { + viewId: { equals: currentViewId }, + }, + }, + onCompleted: (data) => { + setSorts( + data.viewSorts + .map((viewSort) => ({ + ...availableSorts.find((sort) => sort.key === viewSort.key), + label: viewSort.name, + order: viewSort.direction.toLowerCase(), + })) + .filter((sort): sort is SelectedSortType => !!sort), + ); + }, + }); + + const [createViewSortsMutation] = useCreateViewSortsMutation(); + const [updateViewSortMutation] = useUpdateViewSortMutation(); + const [deleteViewSortsMutation] = useDeleteViewSortsMutation(); + + const createViewSorts = useCallback( + (sorts: SelectedSortType[]) => { + if (!currentViewId || !sorts.length) return; + + return createViewSortsMutation({ + variables: { + data: sorts.map((sort) => ({ + key: sort.key, + direction: sort.order as ViewSortDirection, + name: sort.label, + viewId: currentViewId, + })), + }, + refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''], + }); + }, + [createViewSortsMutation, currentViewId], + ); + + const updateViewSorts = useCallback( + (sorts: SelectedSortType[]) => { + if (!currentViewId || !sorts.length) return; + + return Promise.all( + sorts.map((sort) => + updateViewSortMutation({ + variables: { + data: { + direction: sort.order as ViewSortDirection, + }, + where: { + viewId_key: { key: sort.key, viewId: currentViewId }, + }, + }, + refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''], + }), + ), + ); + }, + [currentViewId, updateViewSortMutation], + ); + + const deleteViewSorts = useCallback( + (sortKeys: string[]) => { + if (!currentViewId || !sortKeys.length) return; + + return deleteViewSortsMutation({ + variables: { + where: { + key: { in: sortKeys }, + viewId: { equals: currentViewId }, + }, + }, + refetchQueries: [getOperationName(GET_VIEW_SORTS) ?? ''], + }); + }, + [currentViewId, deleteViewSortsMutation], + ); + + const updateSorts = useCallback( + async (nextSorts: SelectedSortType[]) => { + if (!currentViewId) return; + + const sortsToCreate = nextSorts.filter( + (nextSort) => !sortsByKey[nextSort.key], + ); + await createViewSorts(sortsToCreate); + + const sortsToUpdate = nextSorts.filter( + (nextSort) => + sortsByKey[nextSort.key] && + sortsByKey[nextSort.key].order !== nextSort.order, + ); + await updateViewSorts(sortsToUpdate); + + const nextSortKeys = nextSorts.map((nextSort) => nextSort.key); + const sortKeysToDelete = Object.keys(sortsByKey).filter( + (previousSortKey) => !nextSortKeys.includes(previousSortKey), + ); + return deleteViewSorts(sortKeysToDelete); + }, + [ + createViewSorts, + currentViewId, + deleteViewSorts, + sortsByKey, + updateViewSorts, + ], + ); + + return { updateSorts }; +}; diff --git a/front/src/modules/views/queries/create.ts b/front/src/modules/views/queries/create.ts index a99ada2fd8..ace46507c6 100644 --- a/front/src/modules/views/queries/create.ts +++ b/front/src/modules/views/queries/create.ts @@ -19,3 +19,11 @@ export const CREATE_VIEW_FIELDS = gql` } } `; + +export const CREATE_VIEW_SORTS = gql` + mutation CreateViewSorts($data: [ViewSortCreateManyInput!]!) { + createManyViewSort(data: $data) { + count + } + } +`; diff --git a/front/src/modules/views/queries/delete.ts b/front/src/modules/views/queries/delete.ts new file mode 100644 index 0000000000..15cec37ee2 --- /dev/null +++ b/front/src/modules/views/queries/delete.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_VIEW_SORTS = gql` + mutation DeleteViewSorts($where: ViewSortWhereInput!) { + deleteManyViewSort(where: $where) { + count + } + } +`; diff --git a/front/src/modules/views/queries/select.ts b/front/src/modules/views/queries/select.ts index 735405c482..6cadcde8c1 100644 --- a/front/src/modules/views/queries/select.ts +++ b/front/src/modules/views/queries/select.ts @@ -14,3 +14,13 @@ export const GET_VIEW_FIELDS = gql` } } `; + +export const GET_VIEW_SORTS = gql` + query GetViewSorts($where: ViewSortWhereInput) { + viewSorts: findManyViewSort(where: $where) { + direction + key + name + } + } +`; diff --git a/front/src/modules/views/queries/update.ts b/front/src/modules/views/queries/update.ts index c270d7a6b7..3430eac347 100644 --- a/front/src/modules/views/queries/update.ts +++ b/front/src/modules/views/queries/update.ts @@ -14,3 +14,16 @@ export const UPDATE_VIEW_FIELD = gql` } } `; + +export const UPDATE_VIEW_SORT = gql` + mutation UpdateViewSort( + $data: ViewSortUpdateInput! + $where: ViewSortWhereUniqueInput! + ) { + viewSort: updateOneViewSort(data: $data, where: $where) { + direction + key + name + } + } +`; diff --git a/front/src/modules/views/states/currentViewIdState.ts b/front/src/modules/views/states/currentViewIdState.ts new file mode 100644 index 0000000000..08ceca1c12 --- /dev/null +++ b/front/src/modules/views/states/currentViewIdState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const currentViewIdState = atom({ + key: 'currentViewIdState', + default: undefined, +}); diff --git a/front/src/pages/companies/companies-sorts.tsx b/front/src/pages/companies/companies-sorts.tsx index 71ecc66203..df26454837 100644 --- a/front/src/pages/companies/companies-sorts.tsx +++ b/front/src/pages/companies/companies-sorts.tsx @@ -8,7 +8,7 @@ import { } from '@/ui/icon/index'; import { CompanyOrderByWithRelationInput as Companies_Order_By } from '~/generated/graphql'; -export const availableSorts = [ +export const availableSorts: SortType[] = [ { key: 'name', label: 'Name', @@ -34,4 +34,4 @@ export const availableSorts = [ label: 'Creation', icon: , }, -] satisfies Array>; +]; diff --git a/front/src/pages/people/people-sorts.tsx b/front/src/pages/people/people-sorts.tsx index 9f866f5652..9ece4f20cb 100644 --- a/front/src/pages/people/people-sorts.tsx +++ b/front/src/pages/people/people-sorts.tsx @@ -12,19 +12,15 @@ import { SortOrder as Order_By, } from '~/generated/graphql'; -export const availableSorts = [ +export const availableSorts: SortType[] = [ { key: 'fullname', label: 'People', icon: , - orderByTemplates: [ - (order: Order_By) => ({ - firstName: order, - }), - (order: Order_By) => ({ - lastName: order, - }), + orderByTemplate: (order: Order_By) => [ + { firstName: order }, + { lastName: order }, ], }, { @@ -32,7 +28,7 @@ export const availableSorts = [ label: 'Company', icon: , - orderByTemplates: [(order: Order_By) => ({ company: { name: order } })], + orderByTemplate: (order: Order_By) => [{ company: { name: order } }], }, { key: 'email', @@ -54,4 +50,4 @@ export const availableSorts = [ label: 'City', icon: , }, -] satisfies Array>; +]; diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index 60b307669b..0eb1ffe930 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -17,8 +17,8 @@ import { PipelineStage, PipelineProgress, UserSettings, - ViewField, View, + ViewField, ViewSort, } from '@prisma/client'; @@ -134,11 +134,22 @@ export class AbilityFactory { workspaceId: workspace.id, }); + // View + can(AbilityAction.Read, 'View', { workspaceId: workspace.id }); + can(AbilityAction.Create, 'View', { workspaceId: workspace.id }); + can(AbilityAction.Update, '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 }); + // ViewSort + can(AbilityAction.Read, 'ViewSort', { workspaceId: workspace.id }); + can(AbilityAction.Create, 'ViewSort', { workspaceId: workspace.id }); + can(AbilityAction.Update, 'ViewSort', { workspaceId: workspace.id }); + can(AbilityAction.Delete, 'ViewSort', { workspaceId: workspace.id }); + return build(); } } diff --git a/server/src/ability/ability.module.ts b/server/src/ability/ability.module.ts index 3c5e3a82ce..af72699920 100644 --- a/server/src/ability/ability.module.ts +++ b/server/src/ability/ability.module.ts @@ -99,6 +99,12 @@ import { ReadViewFieldAbilityHandler, UpdateViewFieldAbilityHandler, } from './handlers/view-field.ability-handler'; +import { + CreateViewSortAbilityHandler, + ReadViewSortAbilityHandler, + UpdateViewSortAbilityHandler, + DeleteViewSortAbilityHandler, +} from './handlers/view-sort.ability-handler'; @Global() @Module({ @@ -187,6 +193,11 @@ import { ReadViewFieldAbilityHandler, CreateViewFieldAbilityHandler, UpdateViewFieldAbilityHandler, + // ViewSort + ReadViewSortAbilityHandler, + CreateViewSortAbilityHandler, + UpdateViewSortAbilityHandler, + DeleteViewSortAbilityHandler, ], exports: [ AbilityFactory, @@ -272,6 +283,11 @@ import { ReadViewFieldAbilityHandler, CreateViewFieldAbilityHandler, UpdateViewFieldAbilityHandler, + // ViewSort + ReadViewSortAbilityHandler, + CreateViewSortAbilityHandler, + UpdateViewSortAbilityHandler, + DeleteViewSortAbilityHandler, ], }) export class AbilityModule {} diff --git a/server/src/ability/handlers/view-sort.ability-handler.ts b/server/src/ability/handlers/view-sort.ability-handler.ts new file mode 100644 index 0000000000..262c4deea3 --- /dev/null +++ b/server/src/ability/handlers/view-sort.ability-handler.ts @@ -0,0 +1,122 @@ +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +import { subject } from '@casl/ability'; + +import { IAbilityHandler } from 'src/ability/interfaces/ability-handler.interface'; + +import { AbilityAction } from 'src/ability/ability.action'; +import { AppAbility } from 'src/ability/ability.factory'; +import { + convertToWhereInput, + relationAbilityChecker, +} from 'src/ability/ability.util'; +import { PrismaService } from 'src/database/prisma.service'; +import { assert } from 'src/utils/assert'; +import { ViewSortWhereUniqueInput } from 'src/core/@generated/view-sort/view-sort-where-unique.input'; +import { ViewSortWhereInput } from 'src/core/@generated/view-sort/view-sort-where.input'; + +class ViewSortArgs { + where?: ViewSortWhereInput | ViewSortWhereUniqueInput; + [key: string]: any; +} + +const isViewSortWhereUniqueInput = ( + input: ViewSortWhereInput | ViewSortWhereUniqueInput, +): input is ViewSortWhereUniqueInput => 'viewId_key' in input; + +@Injectable() +export class ReadViewSortAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'ViewSort'); + } +} + +@Injectable() +export class CreateViewSortAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + + const allowed = await relationAbilityChecker( + 'ViewSort', + ability, + this.prismaService.client, + args, + ); + + if (!allowed) { + return false; + } + + return ability.can(AbilityAction.Create, 'ViewSort'); + } +} + +@Injectable() +export class UpdateViewSortAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const viewSort = await this.prismaService.client.viewSort.findFirst({ + where: + args.where && isViewSortWhereUniqueInput(args.where) + ? args.where.viewId_key + : args.where, + }); + assert(viewSort, '', NotFoundException); + + const allowed = await relationAbilityChecker( + 'ViewSort', + ability, + this.prismaService.client, + args, + ); + + if (!allowed) { + return false; + } + + return ability.can(AbilityAction.Update, subject('ViewSort', viewSort)); + } +} + +@Injectable() +export class DeleteViewSortAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + const where = convertToWhereInput( + args.where && isViewSortWhereUniqueInput(args.where) + ? args.where.viewId_key + : args.where, + ); + const viewSorts = await this.prismaService.client.viewSort.findMany({ + where, + }); + assert(viewSorts.length, '', NotFoundException); + + for (const viewSort of viewSorts) { + const allowed = ability.can( + AbilityAction.Delete, + subject('ViewSort', viewSort), + ); + + if (!allowed) { + return false; + } + } + + return true; + } +} diff --git a/server/src/ability/handlers/view.ability-handler.ts b/server/src/ability/handlers/view.ability-handler.ts new file mode 100644 index 0000000000..eec45c15c4 --- /dev/null +++ b/server/src/ability/handlers/view.ability-handler.ts @@ -0,0 +1,79 @@ +import { + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +import { subject } from '@casl/ability'; + +import { IAbilityHandler } from 'src/ability/interfaces/ability-handler.interface'; + +import { AbilityAction } from 'src/ability/ability.action'; +import { AppAbility } from 'src/ability/ability.factory'; +import { relationAbilityChecker } from 'src/ability/ability.util'; +import { ViewWhereInput } from 'src/core/@generated/view/view-where.input'; +import { PrismaService } from 'src/database/prisma.service'; +import { assert } from 'src/utils/assert'; + +class ViewArgs { + where?: ViewWhereInput; + [key: string]: any; +} + +@Injectable() +export class ReadViewAbilityHandler implements IAbilityHandler { + handle(ability: AppAbility) { + return ability.can(AbilityAction.Read, 'View'); + } +} + +@Injectable() +export class CreateViewAbilityHandler implements IAbilityHandler { + constructor(private readonly prismaService: PrismaService) {} + + async handle(ability: AppAbility, context: ExecutionContext) { + const gqlContext = GqlExecutionContext.create(context); + const args = gqlContext.getArgs(); + + const allowed = await relationAbilityChecker( + 'View', + ability, + this.prismaService.client, + args, + ); + + if (!allowed) { + return false; + } + + return ability.can(AbilityAction.Create, 'View'); + } +} + +@Injectable() +export class UpdateViewAbilityHandler 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); + + const allowed = await relationAbilityChecker( + 'View', + ability, + this.prismaService.client, + args, + ); + + if (!allowed) { + return false; + } + + return ability.can(AbilityAction.Update, subject('View', view)); + } +} diff --git a/server/src/core/view/resolvers/view-sort.resolver.spec.ts b/server/src/core/view/resolvers/view-sort.resolver.spec.ts new file mode 100644 index 0000000000..57ae3f7289 --- /dev/null +++ b/server/src/core/view/resolvers/view-sort.resolver.spec.ts @@ -0,0 +1,32 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ViewSortService } from 'src/core/view/services/view-sort.service'; +import { AbilityFactory } from 'src/ability/ability.factory'; + +import { ViewSortResolver } from './view-sort.resolver'; + +describe('ViewSortResolver', () => { + let resolver: ViewSortResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewSortResolver, + { + provide: ViewSortService, + useValue: {}, + }, + { + provide: AbilityFactory, + useValue: {}, + }, + ], + }).compile(); + + resolver = module.get(ViewSortResolver); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); +}); diff --git a/server/src/core/view/resolvers/view-sort.resolver.ts b/server/src/core/view/resolvers/view-sort.resolver.ts new file mode 100644 index 0000000000..6be437b4ab --- /dev/null +++ b/server/src/core/view/resolvers/view-sort.resolver.ts @@ -0,0 +1,102 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { accessibleBy } from '@casl/prisma'; +import { Prisma, Workspace } from '@prisma/client'; + +import { AppAbility } from 'src/ability/ability.factory'; +import { + CreateViewSortAbilityHandler, + DeleteViewSortAbilityHandler, + ReadViewSortAbilityHandler, + UpdateViewSortAbilityHandler, +} from 'src/ability/handlers/view-sort.ability-handler'; +import { FindManyViewSortArgs } from 'src/core/@generated/view-sort/find-many-view-sort.args'; +import { ViewSort } from 'src/core/@generated/view-sort/view-sort.model'; +import { ViewSortService } from 'src/core/view/services/view-sort.service'; +import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; +import { + PrismaSelect, + PrismaSelector, +} from 'src/decorators/prisma-select.decorator'; +import { UserAbility } from 'src/decorators/user-ability.decorator'; +import { AbilityGuard } from 'src/guards/ability.guard'; +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { UpdateOneViewSortArgs } from 'src/core/@generated/view-sort/update-one-view-sort.args'; +import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AffectedRows } from 'src/core/@generated/prisma/affected-rows.output'; +import { DeleteManyViewSortArgs } from 'src/core/@generated/view-sort/delete-many-view-sort.args'; +import { CreateManyViewSortArgs } from 'src/core/@generated/view-sort/create-many-view-sort.args'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => ViewSort) +export class ViewSortResolver { + constructor(private readonly viewSortService: ViewSortService) {} + + @Mutation(() => AffectedRows) + @UseGuards(AbilityGuard) + @CheckAbilities(CreateViewSortAbilityHandler) + async createManyViewSort( + @Args() args: CreateManyViewSortArgs, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewSortService.createMany({ + data: args.data.map((data) => ({ + ...data, + workspaceId: workspace.id, + })), + }); + } + + @Query(() => [ViewSort]) + @UseGuards(AbilityGuard) + @CheckAbilities(ReadViewSortAbilityHandler) + async findManyViewSort( + @Args() args: FindManyViewSortArgs, + @UserAbility() ability: AppAbility, + @PrismaSelector({ modelName: 'ViewSort' }) + prismaSelect: PrismaSelect<'ViewSort'>, + ): Promise[]> { + return this.viewSortService.findMany({ + where: args.where + ? { + AND: [args.where, accessibleBy(ability).ViewSort], + } + : accessibleBy(ability).ViewSort, + orderBy: args.orderBy, + cursor: args.cursor, + take: args.take, + skip: args.skip, + distinct: args.distinct, + select: prismaSelect.value, + }); + } + + @Mutation(() => ViewSort) + @UseGuards(AbilityGuard) + @CheckAbilities(UpdateViewSortAbilityHandler) + async updateOneViewSort( + @Args() args: UpdateOneViewSortArgs, + @PrismaSelector({ modelName: 'ViewSort' }) + prismaSelect: PrismaSelect<'ViewSort'>, + ): Promise> { + return this.viewSortService.update({ + data: args.data, + where: args.where, + select: prismaSelect.value, + } as Prisma.ViewSortUpdateArgs); + } + + @Mutation(() => AffectedRows, { + nullable: false, + }) + @UseGuards(AbilityGuard) + @CheckAbilities(DeleteViewSortAbilityHandler) + async deleteManyViewSort( + @Args() args: DeleteManyViewSortArgs, + ): Promise { + return this.viewSortService.deleteMany({ + where: args.where, + }); + } +} diff --git a/server/src/core/view/services/view-sort.service.spec.ts b/server/src/core/view/services/view-sort.service.spec.ts new file mode 100644 index 0000000000..aa45348767 --- /dev/null +++ b/server/src/core/view/services/view-sort.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { PrismaService } from 'src/database/prisma.service'; +import { prismaMock } from 'src/database/client-mock/jest-prisma-singleton'; + +import { ViewSortService } from './view-sort.service'; + +describe('ViewSortService', () => { + let service: ViewSortService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewSortService, + { + provide: PrismaService, + useValue: prismaMock, + }, + ], + }).compile(); + + service = module.get(ViewSortService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/core/view/services/view-sort.service.ts b/server/src/core/view/services/view-sort.service.ts new file mode 100644 index 0000000000..f6d22b0fbe --- /dev/null +++ b/server/src/core/view/services/view-sort.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from 'src/database/prisma.service'; + +@Injectable() +export class ViewSortService { + constructor(private readonly prismaService: PrismaService) {} + + // Find + findFirst = this.prismaService.client.viewSort.findFirst; + findFirstOrThrow = this.prismaService.client.viewSort.findFirstOrThrow; + + findUnique = this.prismaService.client.viewSort.findUnique; + findUniqueOrThrow = this.prismaService.client.viewSort.findUniqueOrThrow; + + findMany = this.prismaService.client.viewSort.findMany; + + // Create + create = this.prismaService.client.viewSort.create; + createMany = this.prismaService.client.viewSort.createMany; + + // Update + update = this.prismaService.client.viewSort.update; + upsert = this.prismaService.client.viewSort.upsert; + updateMany = this.prismaService.client.viewSort.updateMany; + + // Delete + delete = this.prismaService.client.viewSort.delete; + deleteMany = this.prismaService.client.viewSort.deleteMany; + + // Aggregate + aggregate = this.prismaService.client.viewSort.aggregate; + + // Count + count = this.prismaService.client.viewSort.count; + + // GroupBy + groupBy = this.prismaService.client.viewSort.groupBy; +} diff --git a/server/src/core/view/view.module.ts b/server/src/core/view/view.module.ts index f22071866c..a82b050e0e 100644 --- a/server/src/core/view/view.module.ts +++ b/server/src/core/view/view.module.ts @@ -2,8 +2,15 @@ import { Module } from '@nestjs/common'; import { ViewFieldService } from './services/view-field.service'; import { ViewFieldResolver } from './resolvers/view-field.resolver'; +import { ViewSortService } from './services/view-sort.service'; +import { ViewSortResolver } from './resolvers/view-sort.resolver'; @Module({ - providers: [ViewFieldService, ViewFieldResolver], + providers: [ + ViewFieldService, + ViewSortService, + ViewFieldResolver, + ViewSortResolver, + ], }) export class ViewModule {}