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] 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 9c53a8ec3..b5019d5fd 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 80e292f7b..bb0ddb0ba 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 df38b6e2f..cd5ba3d7d 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 ab80fabd4..0e4bd2ba7 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 818a23134..596e4d2f4 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 27b11d816..7d9021a24 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 1610e1b44..0cba99a24 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 c4bfd99c6..e999449cc 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 bed37bc81..f28457147 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 801531aef..f7cbb5640 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 193479411..c406d8b80 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 1ef6e8ff6..a3f65ef8f 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 000000000..81ec89b00 --- /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 a99ada2fd..ace46507c 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 000000000..15cec37ee --- /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 735405c48..6cadcde8c 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 c270d7a6b..3430eac34 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 000000000..08ceca1c1 --- /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 71ecc6620..df2645483 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 9f866f565..9ece4f20c 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 60b307669..0eb1ffe93 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 3c5e3a82c..af7269992 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 000000000..262c4deea --- /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 000000000..eec45c15c --- /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 000000000..57ae3f728 --- /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 000000000..6be437b4a --- /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 000000000..aa4534876 --- /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 000000000..f6d22b0fb --- /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 f22071866..a82b050e0 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 {}