Feat/pagination front (#2387)

* Finished renaming and scope

* wip

* WIP update

* Ok

* Cleaned

* Finished infinite scroll

* Clean

* Fixed V1 tables

* Fix post merge

* Removed ScrollWrapper

* Put back ScrollWrapper

* Put back in the right place
This commit is contained in:
Lucas Bordeau
2023-11-10 12:43:14 +01:00
committed by GitHub
parent e0289ba9f2
commit 9c29c436b9
29 changed files with 630 additions and 158 deletions

View File

@ -45,6 +45,7 @@
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.0",
"react-intersection-observer": "^9.5.2",
"react-loading-skeleton": "^3.3.1",
"react-phone-number-input": "^3.3.4",
"react-responsive": "^9.0.2",

View File

@ -4,9 +4,12 @@ import {
OperationVariables,
useApolloClient,
} from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { GET_COMPANIES } from '@/companies/graphql/queries/getCompanies';
import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem';
import { generateFindManyCustomObjectsQuery } from '@/metadata/utils/generateFindManyCustomObjectsQuery';
import { GET_PEOPLE } from '@/people/graphql/queries/getPeople';
import { GET_API_KEYS } from '@/settings/developers/graphql/queries/getApiKeys';
import {
@ -16,6 +19,7 @@ import {
} from '~/generated/graphql';
import { optimisticEffectState } from '../states/optimisticEffectState';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = () => {
@ -28,7 +32,7 @@ export const useOptimisticEffect = () => {
definition,
}: {
variables: OperationVariables;
definition: OptimisticEffectDefinition<T>;
definition: OptimisticEffectDefinition;
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
@ -39,12 +43,47 @@ export const useOptimisticEffect = () => {
newData,
query,
variables,
isUsingFlexibleBackend,
objectMetadataItem,
}: {
cache: ApolloCache<unknown>;
newData: unknown[];
newData: unknown;
variables: OperationVariables;
query: DocumentNode;
isUsingFlexibleBackend?: boolean;
objectMetadataItem?: ObjectMetadataItem;
}) => {
if (isUsingFlexibleBackend && objectMetadataItem) {
const generatedQuery = generateFindManyCustomObjectsQuery({
objectMetadataItem,
});
const existingData = cache.readQuery({
query: generatedQuery,
variables,
});
if (!existingData) {
return;
}
cache.writeQuery({
query: generatedQuery,
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentData: (existingData as any)?.[
objectMetadataItem.namePlural
],
newData,
variables,
}),
},
});
return;
}
const existingData = cache.readQuery({
query,
variables,
@ -82,6 +121,7 @@ export const useOptimisticEffect = () => {
},
});
}
if (query === GET_API_KEYS) {
cache.writeQuery({
query,
@ -104,7 +144,9 @@ export const useOptimisticEffect = () => {
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
};
objectMetadataItem: definition.objectMetadataItem,
isUsingFlexibleBackend: definition.isUsingFlexibleBackend,
} satisfies OptimisticEffect<T>;
set(optimisticEffectState, {
...optimisticEffects,
@ -115,26 +157,31 @@ export const useOptimisticEffect = () => {
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
(typename: string, newData: any[]) => {
(typename: string, newData: unknown) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
Object.values(optimisticEffects).forEach((optimisticEffect) => {
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedNewData = newData.map((data) => {
return { ...data, __typename: typename };
});
const formattedNewData = isNonEmptyArray(newData)
? newData.map((data: any) => {
return { ...data, __typename: typename };
})
: newData;
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query,
query: optimisticEffect.query ?? ({} as DocumentNode),
newData: formattedNewData,
variables: optimisticEffect.variables,
isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
});
}
},
);

View File

@ -1,10 +1,14 @@
import { DocumentNode } from 'graphql';
import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition<T> = {
export type OptimisticEffectDefinition = {
key: string;
query: DocumentNode;
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver<T>;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -1,11 +1,11 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver<T> = ({
export type OptimisticEffectResolver = ({
currentData,
newData,
variables,
}: {
currentData: T[];
newData: T[];
currentData: any; //TODO: Change when decommissioning v1
newData: any; //TODO: Change when decommissioning v1
variables: OperationVariables;
}) => void;

View File

@ -1,5 +1,7 @@
import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client';
import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem';
type OptimisticEffectWriter<T> = ({
cache,
newData,
@ -8,14 +10,18 @@ type OptimisticEffectWriter<T> = ({
}: {
cache: ApolloCache<T>;
query: DocumentNode;
newData: T[];
newData: T;
variables: OperationVariables;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
}) => void;
export type OptimisticEffect<T> = {
key: string;
query: DocumentNode;
query?: DocumentNode;
typename: string;
variables: OperationVariables;
writer: OptimisticEffectWriter<T>;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -5,8 +5,8 @@ import { companiesAvailableFieldDefinitions } from '@/companies/constants/compan
import { getCompaniesOptimisticEffectDefinition } from '@/companies/graphql/optimistic-effect-definitions/getCompaniesOptimisticEffectDefinition';
import { useCompanyTableContextMenuEntries } from '@/companies/hooks/useCompanyTableContextMenuEntries';
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
import { RecordTable } from '@/ui/object/record-table/components/RecordTable';
import { RecordTableEffect } from '@/ui/object/record-table/components/RecordTableEffect';
import { RecordTableV1 } from '@/ui/object/record-table/components/RecordTableV1';
import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/ui/object/record-table/options/components/TableOptionsDropdown';
@ -135,7 +135,7 @@ export const CompanyTable = () => {
setContextMenuEntries={setContextMenuEntries}
setActionBarEntries={setActionBarEntries}
/>
<RecordTable
<RecordTableV1
updateEntityMutation={({
variables,
}: {

View File

@ -32,12 +32,14 @@ export const RecordTableContainer = ({
}: {
objectNamePlural: string;
}) => {
const { columnDefinitions } = useFindOneObjectMetadataItem({
objectNamePlural,
});
const { columnDefinitions, foundObjectMetadataItem } =
useFindOneObjectMetadataItem({
objectNamePlural,
});
const { updateOneObject } = useUpdateOneObject({
objectNamePlural,
objectNameSingular: foundObjectMetadataItem?.nameSingular,
});
const tableScopeId = objectNamePlural ?? '';

View File

@ -1,27 +1,25 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2';
import { turnSortsIntoOrderByV2 } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderByV2';
import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { useView } from '@/views/hooks/useView';
import { ViewType } from '@/views/types/ViewType';
import { useRecordTable } from '../../ui/object/record-table/hooks/useRecordTable';
import { useFindManyObjects } from '../hooks/useFindManyObjects';
import { useFindOneObjectMetadataItem } from '../hooks/useFindOneObjectMetadataItem';
import { useTableObjects } from '../hooks/useTableObjects';
export const RecordTableEffect = () => {
const { scopeId } = useRecordTable();
const { scopeId: objectNamePlural, setAvailableTableColumns } =
useRecordTable();
const {
foundObjectMetadataItem,
columnDefinitions,
filterDefinitions,
sortDefinitions,
foundObjectMetadataItem,
} = useFindOneObjectMetadataItem({
objectNamePlural: scopeId,
objectNamePlural,
});
const {
setAvailableSortDefinitions,
setAvailableFilterDefinitions,
@ -30,32 +28,7 @@ export const RecordTableEffect = () => {
setViewObjectId,
} = useView();
const { setRecordTableData, setAvailableTableColumns } = useRecordTable();
const { tableFiltersState, tableSortsState } = useRecordTableScopedStates();
const tableFilters = useRecoilValue(tableFiltersState);
const tableSorts = useRecoilValue(tableSortsState);
const { objects, loading } = useFindManyObjects({
objectNamePlural: scopeId,
filter: turnFiltersIntoWhereClauseV2(
tableFilters,
foundObjectMetadataItem?.fields ?? [],
),
orderBy: turnSortsIntoOrderByV2(
tableSorts,
foundObjectMetadataItem?.fields ?? [],
),
});
useEffect(() => {
if (!loading) {
const entities = objects ?? [];
setRecordTableData(entities);
}
}, [objects, setRecordTableData, loading]);
useTableObjects();
useEffect(() => {
if (!foundObjectMetadataItem) {
@ -70,7 +43,6 @@ export const RecordTableEffect = () => {
setAvailableTableColumns(columnDefinitions);
}, [
setAvailableTableColumns,
setViewObjectId,
setViewType,
columnDefinitions,
@ -80,6 +52,7 @@ export const RecordTableEffect = () => {
foundObjectMetadataItem,
sortDefinitions,
filterDefinitions,
setAvailableTableColumns,
]);
return <></>;

View File

@ -0,0 +1,33 @@
import { produce } from 'immer';
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectMetadataItem } from '@/metadata/types/ObjectMetadataItem';
import { PaginatedObjectTypeResults } from '@/metadata/types/PaginatedObjectTypeResults';
import { capitalize } from '~/utils/string/capitalize';
export const getRecordOptimisticEffectDefinition = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) =>
({
key: `record-create-optimistic-effect-definition-${objectMetadataItem.nameSingular}`,
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
resolver: ({
currentData,
newData,
}: {
currentData: unknown;
newData: unknown;
}) => {
const newRecordPaginatedCacheField = produce<
PaginatedObjectTypeResults<any>
>(currentData as PaginatedObjectTypeResults<any>, (draft) => {
draft.edges.unshift({ node: newData, cursor: '' });
});
return newRecordPaginatedCacheField;
},
isUsingFlexibleBackend: true,
objectMetadataItem,
} satisfies OptimisticEffectDefinition);

View File

@ -1,7 +1,8 @@
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { Currency, FieldMetadataType } from '~/generated-metadata/graphql';
import { capitalize } from '~/utils/string/capitalize';
import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier';
@ -23,10 +24,11 @@ const defaultFieldValues: Record<FieldMetadataType, unknown> = {
export const useCreateOneObject = ({
objectNamePlural,
}: Pick<ObjectMetadataItemIdentifier, 'objectNamePlural'>) => {
const { triggerOptimisticEffects } = useOptimisticEffect();
const {
foundObjectMetadataItem,
objectNotFoundInMetadata,
findManyQuery,
createOneMutation,
} = useFindOneObjectMetadataItem({
objectNamePlural,
@ -36,8 +38,8 @@ export const useCreateOneObject = ({
const [mutate] = useMutation(createOneMutation);
const createOneObject = foundObjectMetadataItem
? (input: Record<string, unknown> = {}) => {
return mutate({
? async (input: Record<string, any>) => {
const createdObject = await mutate({
variables: {
input: {
...foundObjectMetadataItem.fields.reduce(
@ -50,8 +52,14 @@ export const useCreateOneObject = ({
...input,
},
},
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
triggerOptimisticEffects(
`${capitalize(foundObjectMetadataItem.nameSingular)}Edge`,
createdObject.data[
`create${capitalize(foundObjectMetadataItem.nameSingular)}`
],
);
}
: undefined;

View File

@ -1,12 +1,22 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState } from 'recoil';
import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar';
import { logError } from '~/utils/logError';
import { capitalize } from '~/utils/string/capitalize';
import { cursorFamilyState } from '../states/cursorFamilyState';
import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState';
import { isFetchingMoreObjectsFamilyState } from '../states/isFetchingMoreObjectsFamilyState';
import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier';
import { PaginatedObjectType } from '../types/PaginatedObjectType';
import { PaginatedObjectTypeResults } from '../types/PaginatedObjectTypeResults';
import {
PaginatedObjectTypeEdge,
PaginatedObjectTypeResults,
} from '../types/PaginatedObjectTypeResults';
import { formatPagedObjectsToObjects } from '../utils/formatPagedObjectsToObjects';
import { useFindOneObjectMetadataItem } from './useFindOneObjectMetadataItem';
@ -27,6 +37,18 @@ export const useFindManyObjects = <
onCompleted?: (data: PaginatedObjectTypeResults<ObjectType>) => void;
skip?: boolean;
}) => {
const [lastCursor, setLastCursor] = useRecoilState(
cursorFamilyState(objectNamePlural),
);
const [hasNextPage, setHasNextPage] = useRecoilState(
hasNextPageFamilyState(objectNamePlural),
);
const [, setIsFetchingMoreObjects] = useRecoilState(
isFetchingMoreObjectsFamilyState(objectNamePlural),
);
const { foundObjectMetadataItem, objectNotFoundInMetadata, findManyQuery } =
useFindOneObjectMetadataItem({
objectNamePlural,
@ -35,29 +57,107 @@ export const useFindManyObjects = <
const { enqueueSnackBar } = useSnackBar();
const { data, loading, error } = useQuery<PaginatedObjectType<ObjectType>>(
findManyQuery,
{
skip: skip || !foundObjectMetadataItem,
variables: {
filter: filter ?? {},
orderBy: orderBy ?? {},
},
onCompleted: (data) =>
objectNamePlural && onCompleted?.(data[objectNamePlural]),
onError: (error) => {
logError(
`useFindManyObjects for "${objectNamePlural}" error : ` + error,
);
const { data, loading, error, fetchMore } = useQuery<
PaginatedObjectType<ObjectType>
>(findManyQuery, {
skip: skip || !foundObjectMetadataItem || !objectNamePlural,
variables: {
filter: filter ?? {},
orderBy: orderBy ?? {},
},
onCompleted: (data) => {
if (objectNamePlural) {
onCompleted?.(data[objectNamePlural]);
if (objectNamePlural && data?.[objectNamePlural]) {
setLastCursor(data?.[objectNamePlural]?.pageInfo.endCursor);
setHasNextPage(data?.[objectNamePlural]?.pageInfo.hasNextPage);
}
}
},
onError: (error) => {
logError(`useFindManyObjects for "${objectNamePlural}" error : ` + error);
enqueueSnackBar(
`Error during useFindManyObjects for "${objectNamePlural}", ${error.message}`,
{
variant: 'error',
},
);
},
});
const fetchMoreObjects = useCallback(async () => {
if (objectNamePlural && hasNextPage) {
setIsFetchingMoreObjects(true);
try {
await fetchMore({
variables: {
filter: filter ?? {},
orderBy: orderBy ?? {},
lastCursor: isNonEmptyString(lastCursor) ? lastCursor : undefined,
},
updateQuery: (prev, { fetchMoreResult }) => {
const uniqueByCursor = (
a: PaginatedObjectTypeEdge<ObjectType>[],
) => {
const seenCursors = new Set();
return a.filter((item) => {
const currentCursor = item.cursor;
return seenCursors.has(currentCursor)
? false
: seenCursors.add(currentCursor);
});
};
const previousEdges = prev?.[objectNamePlural]?.edges;
const nextEdges = fetchMoreResult?.[objectNamePlural]?.edges;
let newEdges: any[] = [];
if (isNonEmptyArray(previousEdges) && isNonEmptyArray(nextEdges)) {
newEdges = uniqueByCursor([
...prev?.[objectNamePlural]?.edges,
...fetchMoreResult?.[objectNamePlural]?.edges,
]);
}
return Object.assign({}, prev, {
[objectNamePlural]: {
__typename: `${capitalize(
foundObjectMetadataItem?.nameSingular ?? '',
)}Connection`,
edges: newEdges,
pageInfo: fetchMoreResult?.[objectNamePlural].pageInfo,
},
} as PaginatedObjectType<ObjectType>);
},
});
} catch (error) {
logError(`fetchMoreObjects for "${objectNamePlural}" error : ` + error);
enqueueSnackBar(
`Error during useFindManyObjects for "${objectNamePlural}", ${error.message}`,
`Error during fetchMoreObjects for "${objectNamePlural}", ${error}`,
{
variant: 'error',
},
);
},
},
);
} finally {
setIsFetchingMoreObjects(false);
}
}
}, [
objectNamePlural,
lastCursor,
fetchMore,
filter,
orderBy,
foundObjectMetadataItem,
hasNextPage,
setIsFetchingMoreObjects,
enqueueSnackBar,
]);
const objects = useMemo(
() =>
@ -75,5 +175,6 @@ export const useFindManyObjects = <
loading,
error,
objectNotFoundInMetadata,
fetchMoreObjects,
};
};

View File

@ -13,7 +13,7 @@ export const useSetRecordTableData = () => {
return useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(newEntityArray: T[]) => {
<T extends { id: string } & Record<string, any>>(newEntityArray: T[]) => {
for (const entity of newEntityArray) {
const currentEntity = snapshot
.getLoadable(entityFieldsFamilyState(entity.id))

View File

@ -0,0 +1,65 @@
import { useRecoilValue } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { turnFiltersIntoWhereClauseV2 } from '@/ui/object/object-filter-dropdown/utils/turnFiltersIntoWhereClauseV2';
import { turnSortsIntoOrderByV2 } from '@/ui/object/object-sort-dropdown/utils/turnSortsIntoOrderByV2';
import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { getRecordOptimisticEffectDefinition } from '../graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { useFindManyObjects } from './useFindManyObjects';
import { useFindOneObjectMetadataItem } from './useFindOneObjectMetadataItem';
export const useTableObjects = () => {
const { scopeId: objectNamePlural } = useRecordTable();
const { registerOptimisticEffect } = useOptimisticEffect();
const { foundObjectMetadataItem } = useFindOneObjectMetadataItem({
objectNamePlural,
});
const { setRecordTableData } = useRecordTable();
const { tableFiltersState, tableSortsState } = useRecordTableScopedStates();
const tableFilters = useRecoilValue(tableFiltersState);
const tableSorts = useRecoilValue(tableSortsState);
const filter = turnFiltersIntoWhereClauseV2(
tableFilters,
foundObjectMetadataItem?.fields ?? [],
);
const orderBy = turnSortsIntoOrderByV2(
tableSorts,
foundObjectMetadataItem?.fields ?? [],
);
const { objects, loading, fetchMoreObjects } = useFindManyObjects({
objectNamePlural,
filter,
orderBy,
onCompleted: (data) => {
const entities = data.edges.map((edge) => edge.node) ?? [];
setRecordTableData(entities);
if (foundObjectMetadataItem) {
registerOptimisticEffect({
variables: { orderBy, filter },
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: foundObjectMetadataItem,
}),
});
}
},
});
return {
objects,
loading,
fetchMoreObjects,
};
};

View File

@ -1,5 +1,4 @@
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier';
@ -12,7 +11,6 @@ export const useUpdateOneObject = ({
const {
foundObjectMetadataItem,
objectNotFoundInMetadata,
findManyQuery,
updateOneMutation,
} = useFindOneObjectMetadataItem({
objectNamePlural,
@ -20,9 +18,7 @@ export const useUpdateOneObject = ({
});
// TODO: type this with a minimal type at least with Record<string, any>
const [mutate] = useMutation(updateOneMutation, {
refetchQueries: [getOperationName(findManyQuery) ?? ''],
});
const [mutate] = useMutation(updateOneMutation);
const updateOneObject = foundObjectMetadataItem
? ({

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const cursorFamilyState = atomFamily<string, string | undefined>({
key: 'cursorFamilyState',
default: '',
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
export const fetchMoreObjectsFamilyState = atomFamily<
{ fetchMore: () => void },
string
>({
key: 'fetchMoreObjectsFamilyState',
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const hasNextPageFamilyState = atomFamily<boolean, string | undefined>({
key: 'hasNextPageFamilyState',
default: false,
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const isFetchingMoreObjectsFamilyState = atomFamily<
boolean,
string | undefined
>({
key: 'isFetchingMoreObjectsFamilyState',
default: false,
});

View File

@ -1,6 +1,14 @@
export type PaginatedObjectTypeResults<ObjectType extends { id: string }> = {
edges: {
node: ObjectType;
cursor: string;
}[];
export type PaginatedObjectTypeEdge<ObjectType extends { id: string }> = {
node: ObjectType;
cursor: string;
};
export type PaginatedObjectTypeResults<ObjectType extends { id: string }> = {
__typename?: string;
edges: PaginatedObjectTypeEdge<ObjectType>[];
pageInfo: {
hasNextPage: boolean;
startCursor: string;
endCursor: string;
};
};

View File

@ -4,6 +4,8 @@ import { capitalize } from '~/utils/string/capitalize';
import { ObjectMetadataItem } from '../types/ObjectMetadataItem';
import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery';
export const generateCreateOneObjectMutation = ({
objectMetadataItem,
}: {
@ -15,6 +17,9 @@ export const generateCreateOneObjectMutation = ({
mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) {
create${capitalizedObjectName}(data: $input) {
id
${objectMetadataItem.fields
.map(mapFieldMetadataToGraphQLQuery)
.join('\n')}
}
}
`;

View File

@ -8,18 +8,20 @@ import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery
export const generateFindManyCustomObjectsQuery = ({
objectMetadataItem,
_fromCursor,
}: {
objectMetadataItem: ObjectMetadataItem;
_fromCursor?: string;
}) => {
return gql`
query FindMany${objectMetadataItem.namePlural}($filter: ${capitalize(
query FindMany${capitalize(
objectMetadataItem.namePlural,
)}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput, $orderBy: ${capitalize(
objectMetadataItem.nameSingular,
)}OrderByInput) {
${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy){
)}OrderByInput, $lastCursor: String) {
${
objectMetadataItem.namePlural
}(filter: $filter, orderBy: $orderBy, first: 30, after: $lastCursor){
edges {
node {
id
@ -29,6 +31,11 @@ export const generateFindManyCustomObjectsQuery = ({
}
cursor
}
pageInfo {
hasNextPage
startCursor
endCursor
}
}
}
`;

View File

@ -4,6 +4,16 @@ import { capitalize } from '~/utils/string/capitalize';
import { ObjectMetadataItem } from '../types/ObjectMetadataItem';
import { mapFieldMetadataToGraphQLQuery } from './mapFieldMetadataToGraphQLQuery';
export const getUpdateOneObjectMutationGraphQLField = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `update${capitalize(objectNameSingular)}`;
};
export const generateUpdateOneObjectMutation = ({
objectMetadataItem,
}: {
@ -11,10 +21,18 @@ export const generateUpdateOneObjectMutation = ({
}) => {
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const graphQLFieldForUpdateOneObjectMutation =
getUpdateOneObjectMutationGraphQLField({
objectNameSingular: objectMetadataItem.nameSingular,
});
return gql`
mutation UpdateOne${capitalizedObjectName}($idToUpdate: ID!, $input: ${capitalizedObjectName}UpdateInput!) {
update${capitalizedObjectName}(id: $idToUpdate, data: $input) {
${graphQLFieldForUpdateOneObjectMutation}(id: $idToUpdate, data: $input) {
id
${objectMetadataItem.fields
.map(mapFieldMetadataToGraphQLQuery)
.join('\n')}
}
}
`;

View File

@ -5,8 +5,8 @@ import { getPeopleOptimisticEffectDefinition } from '@/people/graphql/optimistic
import { usePersonTableContextMenuEntries } from '@/people/hooks/usePersonTableContextMenuEntries';
import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { RecordTable } from '@/ui/object/record-table/components/RecordTable';
import { RecordTableEffect } from '@/ui/object/record-table/components/RecordTableEffect';
import { RecordTableV1 } from '@/ui/object/record-table/components/RecordTableV1';
import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/ui/object/record-table/options/components/TableOptionsDropdown';
@ -119,7 +119,7 @@ export const PersonTable = () => {
setContextMenuEntries={setContextMenuEntries}
setActionBarEntries={setActionBarEntries}
/>
<RecordTable
<RecordTableV1
updateEntityMutation={({
variables,
}: {

View File

@ -1,78 +1,72 @@
import styled from '@emotion/styled';
import { useVirtual } from '@tanstack/react-virtual';
import { useRecoilValue } from 'recoil';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useScrollWrapperScopedRef } from '@/ui/utilities/scroll/hooks/useScrollWrapperScopedRef';
import { useFindOneObjectMetadataItem } from '@/metadata/hooks/useFindOneObjectMetadataItem';
import { useTableObjects } from '@/metadata/hooks/useTableObjects';
import { isFetchingMoreObjectsFamilyState } from '@/metadata/states/isFetchingMoreObjectsFamilyState';
import { isDefined } from '~/utils/isDefined';
import { RowIdContext } from '../contexts/RowIdContext';
import { RowIndexContext } from '../contexts/RowIndexContext';
import { useRecordTable } from '../hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '../states/isFetchingRecordTableDataState';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { RecordTableRow } from './RecordTableRow';
type SpaceProps = {
top?: number;
bottom?: number;
};
const StyledSpace = styled.td<SpaceProps>`
${({ top }) => top && `padding-top: ${top}px;`}
${({ bottom }) => bottom && `padding-bottom: ${bottom}px;`}
`;
import { RecordTableRow, StyledRow } from './RecordTableRow';
export const RecordTableBody = () => {
const scrollWrapperRef = useScrollWrapperScopedRef();
const { ref: lastTableRowRef, inView: lastTableRowIsVisible } = useInView();
const tableRowIds = useRecoilValue(tableRowIdsState);
const { scopeId: objectNamePlural } = useRecordTable();
const { foundObjectMetadataItem } = useFindOneObjectMetadataItem({
objectNamePlural,
});
const [isFetchingMoreObjects] = useRecoilState(
isFetchingMoreObjectsFamilyState(foundObjectMetadataItem?.namePlural),
);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
const rowVirtualizer = useVirtual({
size: tableRowIds.length,
parentRef: scrollWrapperRef,
overscan: 50,
});
const { fetchMoreObjects } = useTableObjects();
const items = rowVirtualizer.virtualItems;
const paddingTop = items.length > 0 ? items[0].start : 0;
const paddingBottom =
items.length > 0
? rowVirtualizer.totalSize - items[items.length - 1].end
: 0;
useEffect(() => {
if (lastTableRowIsVisible && isDefined(fetchMoreObjects)) {
fetchMoreObjects();
}
}, [lastTableRowIsVisible, fetchMoreObjects]);
const lastRowId = tableRowIds[tableRowIds.length - 1];
if (isFetchingRecordTableData) {
return null;
return <></>;
}
return (
<tbody>
{paddingTop > 0 && (
<tr>
<StyledSpace top={paddingTop} />
</tr>
)}
{items.map((virtualItem) => {
const rowId = tableRowIds[virtualItem.index];
return (
<RowIdContext.Provider value={rowId} key={rowId}>
<RowIndexContext.Provider value={virtualItem.index}>
<RecordTableRow
key={virtualItem.index}
ref={virtualItem.measureRef}
rowId={rowId}
/>
</RowIndexContext.Provider>
</RowIdContext.Provider>
);
})}
{paddingBottom > 0 && (
<tr>
<StyledSpace bottom={paddingBottom} />
</tr>
{tableRowIds.map((rowId, rowIndex) => (
<RowIdContext.Provider value={rowId} key={rowId}>
<RowIndexContext.Provider value={rowIndex}>
<RecordTableRow
key={rowId}
ref={rowId === lastRowId ? lastTableRowRef : undefined}
rowId={rowId}
/>
</RowIndexContext.Provider>
</RowIdContext.Provider>
))}
{isFetchingMoreObjects && (
<StyledRow selected={false}>
<td style={{ height: 50 }} colSpan={1000}>
Fetching more...
</td>
</StyledRow>
)}
</tbody>
);

View File

@ -0,0 +1,32 @@
import { useRecoilValue } from 'recoil';
import { RowIdContext } from '../contexts/RowIdContext';
import { RowIndexContext } from '../contexts/RowIndexContext';
import { isFetchingRecordTableDataState } from '../states/isFetchingRecordTableDataState';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { RecordTableRow } from './RecordTableRow';
export const RecordTableBodyV1 = () => {
const tableRowIds = useRecoilValue(tableRowIdsState);
const isFetchingRecordTableData = useRecoilValue(
isFetchingRecordTableDataState,
);
if (isFetchingRecordTableData) {
return <></>;
}
return (
<tbody>
{tableRowIds.map((rowId, rowIndex) => (
<RowIdContext.Provider value={rowId} key={rowId}>
<RowIndexContext.Provider value={rowIndex}>
<RecordTableRow key={rowId} rowId={rowId} />
</RowIndexContext.Provider>
</RowIdContext.Provider>
))}
</tbody>
);
};

View File

@ -26,8 +26,7 @@ export const RecordTableEffect = ({
}: {
useGetRequest: typeof useGetCompaniesQuery | typeof useGetPeopleQuery;
getRequestResultKey: string;
getRequestOptimisticEffectDefinition: OptimisticEffectDefinition<any>;
getRequestOptimisticEffectDefinition: OptimisticEffectDefinition;
filterDefinitionArray: FilterDefinition[];
sortDefinitionArray: SortDefinition[];
setActionBarEntries?: () => void;

View File

@ -9,7 +9,7 @@ import { useCurrentRowSelected } from '../record-table-row/hooks/useCurrentRowSe
import { CheckboxCell } from './CheckboxCell';
import { RecordTableCell } from './RecordTableCell';
const StyledRow = styled.tr<{ selected: boolean }>`
export const StyledRow = styled.tr<{ selected: boolean }>`
background: ${(props) =>
props.selected ? props.theme.accent.quaternary : 'none'};
`;

View File

@ -0,0 +1,139 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
useListenClickOutside,
useListenClickOutsideByClassName,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { EntityUpdateMutationContext } from '../contexts/EntityUpdateMutationHookContext';
import { useRecordTable } from '../hooks/useRecordTable';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
import { RecordTableBodyV1 } from './RecordTableBodyV1';
import { RecordTableHeader } from './RecordTableHeader';
const StyledTable = styled.table`
border-collapse: collapse;
border-radius: ${({ theme }) => theme.border.radius.sm};
border-spacing: 0;
margin-left: ${({ theme }) => theme.table.horizontalCellMargin};
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
table-layout: fixed;
width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2);
th {
border: 1px solid ${({ theme }) => theme.border.color.light};
border-collapse: collapse;
color: ${({ theme }) => theme.font.color.tertiary};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
}
:last-of-type {
width: 100%;
}
}
td {
border: 1px solid ${({ theme }) => theme.border.color.light};
border-collapse: collapse;
color: ${({ theme }) => theme.font.color.primary};
padding: 0;
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
}
}
`;
const StyledTableWithHeader = styled.div`
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
`;
const StyledTableContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
position: relative;
`;
type RecordTableV1Props = {
updateEntityMutation: (params: any) => void;
};
export const RecordTableV1 = ({ updateEntityMutation }: RecordTableV1Props) => {
const tableBodyRef = useRef<HTMLDivElement>(null);
const {
leaveTableFocus,
setRowSelectedState,
resetTableRowSelection,
useMapKeyboardToSoftFocus,
} = useRecordTable();
useMapKeyboardToSoftFocus();
useListenClickOutside({
refs: [tableBodyRef],
callback: () => {
leaveTableFocus();
},
});
useScopedHotkeys(
'escape',
() => {
resetTableRowSelection();
},
TableHotkeyScope.Table,
);
useListenClickOutsideByClassName({
classNames: ['entity-table-cell'],
excludeClassNames: ['action-bar', 'context-menu'],
callback: () => {
resetTableRowSelection();
},
});
return (
<EntityUpdateMutationContext.Provider value={updateEntityMutation}>
<StyledTableWithHeader>
<StyledTableContainer>
<div ref={tableBodyRef}>
<StyledTable className="entity-table-cell">
<RecordTableHeader />
<RecordTableBodyV1 />
</StyledTable>
<DragSelect
dragSelectable={tableBodyRef}
onDragSelectionStart={resetTableRowSelection}
onDragSelectionChange={setRowSelectedState}
/>
</div>
</StyledTableContainer>
</StyledTableWithHeader>
</EntityUpdateMutationContext.Provider>
);
};

View File

@ -16416,6 +16416,11 @@ react-inspector@^6.0.0:
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d"
integrity sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==
react-intersection-observer@^9.5.2:
version "9.5.2"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz#f68363a1ff292323c0808201b58134307a1626d0"
integrity sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q==
react-is@18.1.0:
version "18.1.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"