feat: persist resized column widths (#1017)

* feat: persist resized column widths

Closes #981

* test: mock company and person view fields
This commit is contained in:
Thaïs 2023-08-02 20:48:14 +02:00 committed by GitHub
parent 552fb2378b
commit 3807d62aeb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 345 additions and 51 deletions

View File

@ -885,6 +885,7 @@ export type Mutation = {
allowImpersonation: WorkspaceMember;
challenge: LoginToken;
createEvent: Analytics;
createManyViewField: AffectedRows;
createOneActivity: Activity;
createOneComment: Comment;
createOneCompany: Company;
@ -934,6 +935,12 @@ export type MutationCreateEventArgs = {
};
export type MutationCreateManyViewFieldArgs = {
data: Array<ViewFieldCreateManyInput>;
skipDuplicates?: InputMaybe<Scalars['Boolean']>;
};
export type MutationCreateOneActivityArgs = {
data: ActivityCreateInput;
};
@ -2076,6 +2083,15 @@ export type ViewField = {
sizeInPx: Scalars['Int'];
};
export type ViewFieldCreateManyInput = {
fieldName: Scalars['String'];
id?: InputMaybe<Scalars['String']>;
index: Scalars['Int'];
isVisible: Scalars['Boolean'];
objectName: Scalars['String'];
sizeInPx: Scalars['Int'];
};
export type ViewFieldOrderByWithRelationInput = {
fieldName?: InputMaybe<SortOrder>;
id?: InputMaybe<SortOrder>;
@ -2640,8 +2656,16 @@ export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }
export type DeleteUserAccountMutation = { __typename?: 'Mutation', deleteUserAccount: { __typename?: 'User', id: string } };
export type CreateViewFieldsMutationVariables = Exact<{
data: Array<ViewFieldCreateManyInput> | ViewFieldCreateManyInput;
}>;
export type CreateViewFieldsMutation = { __typename?: 'Mutation', createManyViewField: { __typename?: 'AffectedRows', count: number } };
export type GetViewFieldsQueryVariables = Exact<{
where?: InputMaybe<ViewFieldWhereInput>;
orderBy?: InputMaybe<Array<ViewFieldOrderByWithRelationInput> | ViewFieldOrderByWithRelationInput>;
}>;
@ -5050,9 +5074,42 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
export const CreateViewFieldsDocument = gql`
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
createManyViewField(data: $data) {
count
}
}
`;
export type CreateViewFieldsMutationFn = Apollo.MutationFunction<CreateViewFieldsMutation, CreateViewFieldsMutationVariables>;
/**
* __useCreateViewFieldsMutation__
*
* To run a mutation, you first call `useCreateViewFieldsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateViewFieldsMutation` 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 [createViewFieldsMutation, { data, loading, error }] = useCreateViewFieldsMutation({
* variables: {
* data: // value for 'data'
* },
* });
*/
export function useCreateViewFieldsMutation(baseOptions?: Apollo.MutationHookOptions<CreateViewFieldsMutation, CreateViewFieldsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateViewFieldsMutation, CreateViewFieldsMutationVariables>(CreateViewFieldsDocument, options);
}
export type CreateViewFieldsMutationHookResult = ReturnType<typeof useCreateViewFieldsMutation>;
export type CreateViewFieldsMutationResult = Apollo.MutationResult<CreateViewFieldsMutation>;
export type CreateViewFieldsMutationOptions = Apollo.BaseMutationOptions<CreateViewFieldsMutation, CreateViewFieldsMutationVariables>;
export const GetViewFieldsDocument = gql`
query GetViewFields($where: ViewFieldWhereInput) {
viewFields: findManyViewField(where: $where) {
query GetViewFields($where: ViewFieldWhereInput, $orderBy: [ViewFieldOrderByWithRelationInput!]) {
viewFields: findManyViewField(where: $where, orderBy: $orderBy) {
id
fieldName
isVisible
@ -5075,6 +5132,7 @@ export const GetViewFieldsDocument = gql`
* const { data, loading, error } = useGetViewFieldsQuery({
* variables: {
* where: // value for 'where'
* orderBy: // value for 'orderBy'
* },
* });
*/

View File

@ -35,11 +35,12 @@ export function CompanyTable() {
return (
<>
<GenericEntityTableData
objectName="company"
getRequestResultKey="companies"
useGetRequest={useGetCompaniesQuery}
orderBy={orderBy}
whereFilters={whereFilters}
viewFields={companyViewFields}
viewFieldDefinitions={companyViewFields}
filterDefinitionArray={companiesFilters}
/>
<EntityTable

View File

@ -1,11 +1,30 @@
import { companyViewFields } from '@/companies/constants/companyViewFields';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { useSetEntityTableData } from '@/ui/table/hooks/useSetEntityTableData';
import { entityTableDimensionsState } from '@/ui/table/states/entityTableDimensionsState';
import { viewFieldsFamilyState } from '@/ui/table/states/viewFieldsState';
import { companyViewFields } from '../../constants/companyViewFields';
import { mockedCompaniesData } from './companies-mock-data';
export function CompanyTableMockData() {
const setEntityTableDimensions = useSetRecoilState(
entityTableDimensionsState,
);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const setEntityTableData = useSetEntityTableData();
setEntityTableData(mockedCompaniesData, companyViewFields, []);
setEntityTableData(mockedCompaniesData, []);
useEffect(() => {
setViewFields(companyViewFields);
setEntityTableDimensions((prevState) => ({
...prevState,
numberOfColumns: companyViewFields.length,
}));
}, [setEntityTableDimensions, setViewFields]);
return <></>;
}

View File

@ -36,11 +36,12 @@ export function PeopleTable() {
return (
<>
<GenericEntityTableData
objectName="person"
getRequestResultKey="people"
useGetRequest={useGetPeopleQuery}
orderBy={orderBy}
whereFilters={whereFilters}
viewFields={peopleViewFields}
viewFieldDefinitions={peopleViewFields}
filterDefinitionArray={peopleFilters}
/>
<EntityTable

View File

@ -1,9 +1,10 @@
import * as React from 'react';
import { useCallback, useRef } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SelectedSortType, SortType } from '@/ui/filter-n-sort/types/interface';
import { useListenClickOutside } from '@/ui/utilities/click-outside/hooks/useListenClickOutside';
import { useUpdateViewFieldMutation } from '~/generated/graphql';
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
@ -102,8 +103,11 @@ export function EntityTable<SortField>({
useUpdateEntityMutation,
}: OwnProps<SortField>) {
const viewFields = useRecoilValue(viewFieldsFamilyState);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const tableBodyRef = React.useRef<HTMLDivElement>(null);
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
const tableBodyRef = useRef<HTMLDivElement>(null);
useMapKeyboardToSoftFocus();
@ -116,6 +120,25 @@ export function EntityTable<SortField>({
},
});
const handleColumnResize = useCallback(
(resizedFieldId: string, width: number) => {
setViewFields((previousViewFields) =>
previousViewFields.map((viewField) =>
viewField.id === resizedFieldId
? { ...viewField, columnSize: width }
: viewField,
),
);
updateViewFieldMutation({
variables: {
data: { sizeInPx: width },
where: { id: resizedFieldId },
},
});
},
[setViewFields, updateViewFieldMutation],
);
return (
<EntityUpdateMutationHookContext.Provider value={useUpdateEntityMutation}>
<StyledTableWithHeader>
@ -129,7 +152,10 @@ export function EntityTable<SortField>({
<StyledTableWrapper>
{viewFields.length > 0 && (
<StyledTable>
<EntityTableHeader viewFields={viewFields} />
<EntityTableHeader
onColumnResize={handleColumnResize}
viewFields={viewFields}
/>
<EntityTableBody />
</StyledTable>
)}

View File

@ -1,7 +1,10 @@
import { PointerEvent, useCallback, useState } from 'react';
import { PointerEvent, useCallback, useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { ViewFieldDefinition, ViewFieldMetadata } from '../types/ViewField';
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '../types/ViewField';
import { ColumnHead } from './ColumnHead';
import { SelectAllCheckbox } from './SelectAllCheckbox';
@ -40,18 +43,22 @@ const StyledResizeHandler = styled.div`
`;
type OwnProps = {
onColumnResize: (resizedFieldId: string, width: number) => void;
viewFields: ViewFieldDefinition<ViewFieldMetadata>[];
};
export function EntityTableHeader({ viewFields }: OwnProps) {
const initialColumnWidths = viewFields.reduce<Record<string, number>>(
(result, viewField) => ({
...result,
[viewField.id]: viewField.columnSize,
}),
{},
export function EntityTableHeader({ onColumnResize, viewFields }: OwnProps) {
const columnWidths = useMemo(
() =>
viewFields.reduce<Record<string, number>>(
(result, viewField) => ({
...result,
[viewField.id]: viewField.columnSize,
}),
{},
),
[viewFields],
);
const [columnWidths, setColumnWidths] = useState(initialColumnWidths);
const [isResizing, setIsResizing] = useState(false);
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
number | null
@ -82,16 +89,16 @@ export function EntityTableHeader({ viewFields }: OwnProps) {
setIsResizing(false);
if (!resizedFieldId) return;
const newColumnWidths = {
...columnWidths,
[resizedFieldId]: Math.max(
columnWidths[resizedFieldId] + offset,
COLUMN_MIN_WIDTH,
),
};
setColumnWidths(newColumnWidths);
const nextWidth = Math.round(
Math.max(columnWidths[resizedFieldId] + offset, COLUMN_MIN_WIDTH),
);
if (nextWidth !== columnWidths[resizedFieldId]) {
onColumnResize(resizedFieldId, nextWidth);
}
setOffset(0);
}, [offset, setIsResizing, columnWidths, resizedFieldId]);
}, [resizedFieldId, columnWidths, offset, onColumnResize]);
return (
<thead>

View File

@ -1,3 +1,4 @@
import { defaultOrderBy } from '@/people/queries';
import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition';
import { useSetEntityTableData } from '@/ui/table/hooks/useSetEntityTableData';
import {
@ -5,31 +6,35 @@ import {
ViewFieldMetadata,
} from '@/ui/table/types/ViewField';
import { defaultOrderBy } from '../../../people/queries';
import { useLoadView } from '../hooks/useLoadView';
export function GenericEntityTableData({
objectName,
useGetRequest,
getRequestResultKey,
orderBy = defaultOrderBy,
whereFilters,
viewFields,
viewFieldDefinitions,
filterDefinitionArray,
}: {
objectName: 'company' | 'person';
useGetRequest: any;
getRequestResultKey: string;
orderBy?: any;
whereFilters?: any;
viewFields: ViewFieldDefinition<ViewFieldMetadata>[];
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
filterDefinitionArray: FilterDefinition[];
}) {
const setEntityTableData = useSetEntityTableData();
useLoadView({ objectName, viewFieldDefinitions });
useGetRequest({
variables: { orderBy, where: whereFilters },
onCompleted: (data: any) => {
const entities = data[getRequestResultKey] ?? [];
setEntityTableData(entities, viewFields, filterDefinitionArray);
setEntityTableData(entities, filterDefinitionArray);
},
});

View File

@ -0,0 +1,81 @@
import { getOperationName } from '@apollo/client/utilities';
import { useSetRecoilState } from 'recoil';
import { GET_VIEW_FIELDS } from '@/views/queries/select';
import {
SortOrder,
useCreateViewFieldsMutation,
useGetViewFieldsQuery,
} from '~/generated/graphql';
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
import { viewFieldsFamilyState } from '../states/viewFieldsState';
import {
ViewFieldDefinition,
ViewFieldMetadata,
ViewFieldTextMetadata,
} from '../types/ViewField';
const DEFAULT_VIEW_FIELD_METADATA: ViewFieldTextMetadata = {
type: 'text',
placeHolder: '',
fieldName: '',
};
export const useLoadView = ({
objectName,
viewFieldDefinitions,
}: {
objectName: 'company' | 'person';
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
}) => {
const setEntityTableDimensions = useSetRecoilState(
entityTableDimensionsState,
);
const setViewFields = useSetRecoilState(viewFieldsFamilyState);
const [createViewFieldsMutation] = useCreateViewFieldsMutation();
useGetViewFieldsQuery({
variables: {
orderBy: { index: SortOrder.Asc },
where: { objectName: { equals: objectName } },
},
onCompleted: (data) => {
if (data.viewFields.length) {
setViewFields(
data.viewFields.map<ViewFieldDefinition<ViewFieldMetadata>>(
(viewField) => ({
...(viewFieldDefinitions.find(
({ columnLabel }) => viewField.fieldName === columnLabel,
) || { metadata: DEFAULT_VIEW_FIELD_METADATA }),
id: viewField.id,
columnLabel: viewField.fieldName,
columnOrder: viewField.index,
columnSize: viewField.sizeInPx,
}),
),
);
setEntityTableDimensions((prevState) => ({
...prevState,
numberOfColumns: data.viewFields.length,
}));
return;
}
// Populate if empty
createViewFieldsMutation({
variables: {
data: viewFieldDefinitions.map((viewFieldDefinition) => ({
fieldName: viewFieldDefinition.columnLabel,
index: viewFieldDefinition.columnOrder,
isVisible: true,
objectName,
sizeInPx: viewFieldDefinition.columnSize,
})),
},
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
});
},
});
};

View File

@ -8,11 +8,6 @@ import { isFetchingEntityTableDataState } from '@/ui/table/states/isFetchingEnti
import { TableContext } from '@/ui/table/states/TableContext';
import { tableEntitiesFamilyState } from '@/ui/table/states/tableEntitiesFamilyState';
import { tableRowIdsState } from '@/ui/table/states/tableRowIdsState';
import { viewFieldsFamilyState } from '@/ui/table/states/viewFieldsState';
import {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/table/types/ViewField';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
export function useSetEntityTableData() {
@ -24,7 +19,6 @@ export function useSetEntityTableData() {
({ set, snapshot }) =>
<T extends { id: string }>(
newEntityArray: T[],
viewFields: ViewFieldDefinition<ViewFieldMetadata>[],
filters: FilterDefinition[],
) => {
for (const entity of newEntityArray) {
@ -49,15 +43,13 @@ export function useSetEntityTableData() {
resetTableRowSelection();
set(entityTableDimensionsState, {
numberOfColumns: viewFields.length,
set(entityTableDimensionsState, (prevState) => ({
...prevState,
numberOfRows: entityIds.length,
});
}));
set(availableFiltersScopedState(tableContextScopeId), filters);
set(viewFieldsFamilyState, viewFields);
set(isFetchingEntityTableDataState, false);
},
[],

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_VIEW_FIELDS = gql`
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
createManyViewField(data: $data) {
count
}
}
`;

View File

@ -1,8 +1,11 @@
import { gql } from '@apollo/client';
export const GET_VIEW_FIELDS = gql`
query GetViewFields($where: ViewFieldWhereInput) {
viewFields: findManyViewField(where: $where) {
query GetViewFields(
$where: ViewFieldWhereInput
$orderBy: [ViewFieldOrderByWithRelationInput!]
) {
viewFields: findManyViewField(where: $where, orderBy: $orderBy) {
id
fieldName
isVisible

View File

@ -13,6 +13,7 @@ import {
SEARCH_USER_QUERY,
} from '@/search/queries/search';
import { GET_CURRENT_USER } from '@/users/queries';
import { GET_VIEW_FIELDS } from '@/views/queries/select';
import {
GetCompaniesQuery,
GetPeopleQuery,
@ -24,8 +25,11 @@ import {
} from '~/generated/graphql';
import { mockedActivities } from './mock-data/activities';
import { mockedCompaniesData } from './mock-data/companies';
import { mockedPeopleData } from './mock-data/people';
import {
mockedCompaniesData,
mockedCompanyViewFields,
} from './mock-data/companies';
import { mockedPeopleData, mockedPersonViewFields } from './mock-data/people';
import { mockedPipelineProgressData } from './mock-data/pipeline-progress';
import { mockedPipelinesData } from './mock-data/pipelines';
import { mockedUsersData } from './mock-data/users';
@ -206,4 +210,20 @@ export const graphqlMocks = [
}),
);
}),
graphql.query(getOperationName(GET_VIEW_FIELDS) ?? '', (req, res, ctx) => {
const {
where: {
objectName: { equals: objectName },
},
} = req.variables;
return res(
ctx.data({
viewFields:
objectName === 'company'
? mockedCompanyViewFields
: mockedPersonViewFields,
}),
);
}),
];

View File

@ -1,4 +1,5 @@
import { Company, User } from '../../generated/graphql';
import { companyViewFields } from '@/companies/constants/companyViewFields';
import { Company, User, ViewField } from '~/generated/graphql';
type MockedCompany = Pick<
Company,
@ -118,3 +119,15 @@ export const mockedCompaniesData: Array<MockedCompany> = [
__typename: 'Company',
},
];
export const mockedCompanyViewFields = companyViewFields.map<ViewField>(
(viewFieldDefinition) => ({
__typename: 'ViewField',
fieldName: viewFieldDefinition.columnLabel,
id: viewFieldDefinition.id,
index: viewFieldDefinition.columnOrder,
isVisible: true,
objectName: 'company',
sizeInPx: viewFieldDefinition.columnSize,
}),
);

View File

@ -1,4 +1,5 @@
import { Company, Person } from '~/generated/graphql';
import { peopleViewFields } from '@/people/constants/peopleViewFields';
import { Company, Person, ViewField } from '~/generated/graphql';
type RequiredAndNotNull<T> = {
[P in keyof T]-?: Exclude<T[P], null | undefined>;
@ -116,3 +117,15 @@ export const mockedPeopleData: MockedPerson[] = [
city: 'Paris',
},
];
export const mockedPersonViewFields = peopleViewFields.map<ViewField>(
(viewFieldDefinition) => ({
__typename: 'ViewField',
fieldName: viewFieldDefinition.columnLabel,
id: viewFieldDefinition.id,
index: viewFieldDefinition.columnOrder,
isVisible: true,
objectName: 'person',
sizeInPx: viewFieldDefinition.columnSize,
}),
);

View File

@ -132,6 +132,7 @@ export class AbilityFactory {
// ViewField
can(AbilityAction.Read, 'ViewField', { workspaceId: workspace.id });
can(AbilityAction.Create, 'ViewField', { workspaceId: workspace.id });
can(AbilityAction.Update, 'ViewField', { workspaceId: workspace.id });
return build();

View File

@ -95,6 +95,7 @@ import {
UpdateAttachmentAbilityHandler,
} from './handlers/attachment.ability-handler';
import {
CreateViewFieldAbilityHandler,
ReadViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
} from './handlers/view-field.ability-handler';
@ -184,6 +185,7 @@ import {
DeletePipelineProgressAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
],
exports: [
@ -268,6 +270,7 @@ import {
DeletePipelineProgressAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
],
})

View File

@ -28,6 +28,29 @@ export class ReadViewFieldAbilityHandler implements IAbilityHandler {
}
}
@Injectable()
export class CreateViewFieldAbilityHandler 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(
'ViewField',
ability,
this.prismaService.client,
args,
);
if (!allowed) {
return false;
}
return ability.can(AbilityAction.Create, 'ViewField');
}
}
@Injectable()
export class UpdateViewFieldAbilityHandler implements IAbilityHandler {
constructor(private readonly prismaService: PrismaService) {}

View File

@ -2,17 +2,21 @@ import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { accessibleBy } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import { Prisma, Workspace } from '@prisma/client';
import { AppAbility } from 'src/ability/ability.factory';
import {
CreateViewFieldAbilityHandler,
ReadViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
} from 'src/ability/handlers/view-field.ability-handler';
import { AffectedRows } from 'src/core/@generated/prisma/affected-rows.output';
import { CreateManyViewFieldArgs } from 'src/core/@generated/view-field/create-many-view-field.args';
import { FindManyViewFieldArgs } from 'src/core/@generated/view-field/find-many-view-field.args';
import { UpdateOneViewFieldArgs } from 'src/core/@generated/view-field/update-one-view-field.args';
import { ViewField } from 'src/core/@generated/view-field/view-field.model';
import { ViewFieldService } from 'src/core/view/services/view-field.service';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import {
PrismaSelect,
@ -27,6 +31,21 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
export class ViewFieldResolver {
constructor(private readonly viewFieldService: ViewFieldService) {}
@Mutation(() => AffectedRows)
@UseGuards(AbilityGuard)
@CheckAbilities(CreateViewFieldAbilityHandler)
async createManyViewField(
@Args() args: CreateManyViewFieldArgs,
@AuthWorkspace() workspace: Workspace,
): Promise<Prisma.BatchPayload> {
return this.viewFieldService.createMany({
data: args.data.map((dataItem) => ({
...dataItem,
workspaceId: workspace.id,
})),
});
}
@Query(() => [ViewField])
@UseGuards(AbilityGuard)
@CheckAbilities(ReadViewFieldAbilityHandler)