fix: wrong record count on deleted and normal records (#21292)
## Summary - Resolves #11977 - When looking into the deleted records from People tab (or any object list), the record detail header showing 0/(total records) instead of the correct position among deleted records only, e.g. 1/3 or 3/7. So, this PR makes the count match what users see in the deleted-records list. - Also normal records showing `0/N` in the header when opened from a list view (e.g. `0/48` -> `2/48`). ## Approach I tried to keep the change small and avoid extra server requests: - when a user came from a deleted-records view, we tell our existing queries to include soft-deleted records. - for the position number, we use the record list the user already had open (from the index view they came from) instead of apollo cache, which didn’t include records, especially deleted ones, but also normal records. - normal list behavior is not changed on the server side. ## Test plan - Open people/company, delete a record - Use the side menu -> “see deleted records” - open a deleted record’s details - confirm the header showing the correct position and total (e.g. 1/2, not 0/100) - for normal list: open People (normal list, not deleted) -> click a record -> open full page -> confirm header shows correct position and total (e.g. `2/48`, not `0/48`) ## Screenshots ### Before: <img width="1513" height="309" alt="Screenshot 2026-06-07 135204" src="https://github.com/user-attachments/assets/4754f1a7-8315-4a7a-815f-dda977b09331" /> <img width="1514" height="261" alt="Screenshot 2026-06-07 141735" src="https://github.com/user-attachments/assets/dd5b1834-5d84-49fe-8d20-633428d73502" /> ### After: <img width="1511" height="224" alt="Screenshot 2026-06-07 134946" src="https://github.com/user-attachments/assets/9450af7d-84b9-40bb-95e9-5a8665cc0923" /> <img width="1514" height="288" alt="Screenshot 2026-06-07 135045" src="https://github.com/user-attachments/assets/029ae632-ad7e-451e-8170-a4e4e71ac6f9" /> <img width="1512" height="229" alt="Screenshot 2026-06-07 141642" src="https://github.com/user-attachments/assets/576f4cad-a9e9-4380-aa67-e5f0e976a193" /> --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
committed by
GitHub
parent
7bff4403fc
commit
e04eef0461
+114
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type RecordGqlOperationFilter,
|
||||
type RecordGqlOperationOrderBy,
|
||||
} from 'twenty-shared/types';
|
||||
import { isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
type CursorOrderByField = {
|
||||
fieldName: string;
|
||||
direction: string;
|
||||
subFieldName?: string;
|
||||
};
|
||||
|
||||
const isAscendingOrder = (direction: string): boolean =>
|
||||
direction === 'AscNullsFirst' || direction === 'AscNullsLast';
|
||||
|
||||
const computeOperator = (
|
||||
isAscending: boolean,
|
||||
isForwardPagination: boolean,
|
||||
): string =>
|
||||
(isAscending ? isForwardPagination : !isForwardPagination) ? 'gt' : 'lt';
|
||||
|
||||
const getCursorValue = (
|
||||
record: Record<string, unknown>,
|
||||
field: CursorOrderByField,
|
||||
): unknown => {
|
||||
if (field.subFieldName) {
|
||||
return (record[field.fieldName] as Record<string, unknown> | undefined)?.[
|
||||
field.subFieldName
|
||||
];
|
||||
}
|
||||
|
||||
return record[field.fieldName];
|
||||
};
|
||||
|
||||
const buildCursorWhereCondition = (
|
||||
field: CursorOrderByField,
|
||||
operator: string,
|
||||
value: unknown,
|
||||
): RecordGqlOperationFilter =>
|
||||
field.subFieldName
|
||||
? { [field.fieldName]: { [field.subFieldName]: { [operator]: value } } }
|
||||
: { [field.fieldName]: { [operator]: value } };
|
||||
|
||||
const resolveOrderByFields = (
|
||||
orderBy: RecordGqlOperationOrderBy,
|
||||
): CursorOrderByField[] => {
|
||||
const fields: CursorOrderByField[] = [];
|
||||
|
||||
for (const entry of orderBy) {
|
||||
for (const [fieldName, value] of Object.entries(entry)) {
|
||||
if (isOrderByDirection(value)) {
|
||||
fields.push({ fieldName, direction: value });
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const [subFieldName, subValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
if (isOrderByDirection(subValue)) {
|
||||
fields.push({ fieldName, direction: subValue, subFieldName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fields.some((field) => field.fieldName === 'id')) {
|
||||
fields.push({ fieldName: 'id', direction: 'AscNullsFirst' });
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
export const computeCursorArgFilter = ({
|
||||
orderBy,
|
||||
cursorRecordValues,
|
||||
isForwardPagination,
|
||||
}: {
|
||||
orderBy: RecordGqlOperationOrderBy;
|
||||
cursorRecordValues: Record<string, unknown>;
|
||||
isForwardPagination: boolean;
|
||||
}): RecordGqlOperationFilter => {
|
||||
const fields = resolveOrderByFields(orderBy);
|
||||
|
||||
const cumulativeConditions: RecordGqlOperationFilter[] = fields.map(
|
||||
(field, index) => {
|
||||
const equalityPrefixes = fields
|
||||
.slice(0, index)
|
||||
.map((prevField) =>
|
||||
buildCursorWhereCondition(
|
||||
prevField,
|
||||
'eq',
|
||||
getCursorValue(cursorRecordValues, prevField),
|
||||
),
|
||||
);
|
||||
|
||||
const ascending = isAscendingOrder(field.direction);
|
||||
const operator = computeOperator(ascending, isForwardPagination);
|
||||
const comparison = buildCursorWhereCondition(
|
||||
field,
|
||||
operator,
|
||||
getCursorValue(cursorRecordValues, field),
|
||||
);
|
||||
|
||||
const conditions = [...equalityPrefixes, comparison];
|
||||
|
||||
return conditions.length === 1 ? conditions[0] : { and: conditions };
|
||||
},
|
||||
);
|
||||
|
||||
if (cumulativeConditions.length === 0) return {};
|
||||
|
||||
return { or: cumulativeConditions };
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
import { isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
export const extractOrderByFieldNames = (
|
||||
orderBy: RecordGqlOperationOrderBy,
|
||||
): Record<string, boolean | Record<string, boolean>> => {
|
||||
const gqlFields: Record<string, boolean | Record<string, boolean>> = {
|
||||
id: true,
|
||||
};
|
||||
|
||||
for (const entry of orderBy) {
|
||||
for (const [fieldName, value] of Object.entries(entry)) {
|
||||
if (isOrderByDirection(value)) {
|
||||
gqlFields[fieldName] = true;
|
||||
} else if (isPlainObject(value)) {
|
||||
const subFields: Record<string, boolean> = {};
|
||||
|
||||
for (const [subFieldName, subValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
if (isOrderByDirection(subValue)) {
|
||||
subFields[subFieldName] = true;
|
||||
}
|
||||
}
|
||||
gqlFields[fieldName] = subFields;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gqlFields;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
const ORDER_DIRECTIONS = new Set([
|
||||
'AscNullsFirst',
|
||||
'AscNullsLast',
|
||||
'DescNullsFirst',
|
||||
'DescNullsLast',
|
||||
]);
|
||||
|
||||
export const isOrderByDirection = (value: unknown): value is string =>
|
||||
isNonEmptyString(value) && ORDER_DIRECTIONS.has(value);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
import { isPlainObject } from 'twenty-shared/utils';
|
||||
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
const REVERSE_DIRECTION: Record<string, string> = {
|
||||
AscNullsFirst: 'DescNullsLast',
|
||||
AscNullsLast: 'DescNullsFirst',
|
||||
DescNullsFirst: 'AscNullsLast',
|
||||
DescNullsLast: 'AscNullsFirst',
|
||||
};
|
||||
|
||||
type OrderByEntry = RecordGqlOperationOrderBy[number];
|
||||
type OrderByValue = OrderByEntry[string];
|
||||
|
||||
export const reverseOrderBy = (
|
||||
orderBy: RecordGqlOperationOrderBy,
|
||||
): RecordGqlOperationOrderBy =>
|
||||
orderBy.map((entry) => {
|
||||
const reversed: OrderByEntry = {};
|
||||
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
reversed[key] = reverseValue(value);
|
||||
}
|
||||
|
||||
return reversed;
|
||||
});
|
||||
|
||||
const reverseValue = (value: OrderByValue): OrderByValue => {
|
||||
if (isOrderByDirection(value)) {
|
||||
return (REVERSE_DIRECTION[value] ?? value) as OrderByValue;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const reversed: OrderByEntry = {};
|
||||
|
||||
for (const [key, subValue] of Object.entries(value as OrderByEntry)) {
|
||||
reversed[key] = reverseValue(subValue);
|
||||
}
|
||||
|
||||
return reversed;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
+2
-1
@@ -336,7 +336,8 @@ export const isRecordMatchingFilter = ({
|
||||
});
|
||||
}
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC: {
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION: {
|
||||
return isMatchingFloatFilter({
|
||||
floatFilter: filterValue as FloatFilter,
|
||||
value: record[filterKey],
|
||||
|
||||
+8
-1
@@ -9,6 +9,7 @@ import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/u
|
||||
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
|
||||
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
@@ -56,6 +57,8 @@ export const ObjectRecordShowPageBreadcrumb = ({
|
||||
objectLabel: string;
|
||||
labelIdentifierFieldMetadataItem?: FieldMetadataItem;
|
||||
}) => {
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
const { loading } = useFindOneRecord({
|
||||
objectNameSingular,
|
||||
objectRecordId,
|
||||
@@ -81,7 +84,11 @@ export const ObjectRecordShowPageBreadcrumb = ({
|
||||
const { navigateToIndexView, rankInView, totalCount } =
|
||||
useRecordShowPagePagination(objectNameSingular, objectRecordId);
|
||||
|
||||
if (loading) {
|
||||
if (!loading && isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
|
||||
if (isInitialLoad && loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+151
-157
@@ -1,23 +1,22 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
|
||||
import { computeCursorArgFilter } from '@/object-record/graphql/utils/computeCursorArgFilter';
|
||||
import { extractOrderByFieldNames } from '@/object-record/graphql/utils/extractOrderByFieldNames';
|
||||
import { reverseOrderBy } from '@/object-record/graphql/utils/reverseOrderBy';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useRecordIdsFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery';
|
||||
import { useQueryVariablesFromParentView } from '@/views/hooks/useQueryVariablesFromParentView';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { combineFilters, isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
export const useRecordShowPagePagination = (
|
||||
propsObjectNameSingular: string,
|
||||
propsObjectRecordId: string,
|
||||
) => {
|
||||
const { t } = useLingui();
|
||||
const {
|
||||
objectNameSingular: paramObjectNameSingular,
|
||||
objectRecordId: paramObjectRecordId,
|
||||
@@ -36,207 +35,202 @@ export const useRecordShowPagePagination = (
|
||||
throw new Error('Object name or Record id is not defined');
|
||||
}
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
|
||||
|
||||
const { filter, orderBy } = useQueryVariablesFromParentView({
|
||||
objectMetadataItem,
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { loading: loadingCursor, pageInfo: currentRecordsPageInfo } =
|
||||
const { filter, orderBy, isSoftDeleteFilterActive } =
|
||||
useQueryVariablesFromParentView({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const orderByGqlFields = extractOrderByFieldNames(orderBy);
|
||||
|
||||
const reversedOrderBy = reverseOrderBy(orderBy);
|
||||
|
||||
const { loading: loadingCurrentRecord, records: currentRecords } =
|
||||
useFindManyRecords({
|
||||
filter: {
|
||||
id: { eq: objectRecordId },
|
||||
},
|
||||
filter: { id: { eq: objectRecordId } },
|
||||
orderBy,
|
||||
limit: 1,
|
||||
objectNameSingular,
|
||||
recordGqlFields: { id: true },
|
||||
recordGqlFields: { ...orderByGqlFields, deletedAt: true },
|
||||
withSoftDeleted: true,
|
||||
});
|
||||
|
||||
const currentRecordCursorFromRequest = currentRecordsPageInfo?.endCursor;
|
||||
const currentRecord = currentRecords[0];
|
||||
const isCurrentRecordDeleted = isDefined(currentRecord?.deletedAt);
|
||||
const withSoftDeleted = isSoftDeleteFilterActive || isCurrentRecordDeleted;
|
||||
|
||||
const [totalCountBefore, setTotalCountBefore] = useState<number>(0);
|
||||
const [totalCountAfter, setTotalCountAfter] = useState<number>(0);
|
||||
const deletedOnlyFilter = isCurrentRecordDeleted
|
||||
? { deletedAt: { is: 'NOT_NULL' as const } }
|
||||
: undefined;
|
||||
|
||||
const { loading: loadingRecordBefore, records: recordsBefore } =
|
||||
const currentRecordKeysetValues: Record<string, unknown> | undefined =
|
||||
isDefined(currentRecord)
|
||||
? {
|
||||
id: currentRecord.id,
|
||||
...Object.fromEntries(
|
||||
Object.keys(orderByGqlFields).map((fieldName) => [
|
||||
fieldName,
|
||||
currentRecord[fieldName],
|
||||
]),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const beforeFilter = isDefined(currentRecordKeysetValues)
|
||||
? computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: currentRecordKeysetValues,
|
||||
isForwardPagination: false,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const afterFilter = isDefined(currentRecordKeysetValues)
|
||||
? computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: currentRecordKeysetValues,
|
||||
isForwardPagination: true,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const hasKeysetFilters = isDefined(beforeFilter) && isDefined(afterFilter);
|
||||
const skipNeighborQueries = loadingCurrentRecord || !hasKeysetFilters;
|
||||
|
||||
const baseNeighborOptions = {
|
||||
skip: skipNeighborQueries,
|
||||
objectNameSingular,
|
||||
recordGqlFields: { id: true },
|
||||
withSoftDeleted,
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const mergedFilter = combineFilters(
|
||||
[filter, deletedOnlyFilter].filter(isDefined),
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loadingRecordBefore,
|
||||
records: recordsBefore,
|
||||
totalCount: totalCountBefore,
|
||||
} = useFindManyRecords({
|
||||
...baseNeighborOptions,
|
||||
fetchPolicy: 'network-only',
|
||||
filter: combineFilters([mergedFilter, beforeFilter].filter(isDefined)),
|
||||
orderBy: reversedOrderBy,
|
||||
});
|
||||
|
||||
const {
|
||||
loading: loadingRecordAfter,
|
||||
records: recordsAfter,
|
||||
totalCount: totalCountAfter,
|
||||
} = useFindManyRecords({
|
||||
...baseNeighborOptions,
|
||||
fetchPolicy: 'network-only',
|
||||
filter: combineFilters([mergedFilter, afterFilter].filter(isDefined)),
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const isAtFirstRecord = !loadingRecordBefore && totalCountBefore === 0;
|
||||
const isAtLastRecord = !loadingRecordAfter && totalCountAfter === 0;
|
||||
|
||||
const { loading: loadingFirstRecord, records: firstRecords } =
|
||||
useFindManyRecords({
|
||||
skip: loadingCursor,
|
||||
fetchPolicy: 'network-only',
|
||||
filter: {
|
||||
...filter,
|
||||
id: { neq: objectRecordId },
|
||||
},
|
||||
...baseNeighborOptions,
|
||||
skip: skipNeighborQueries || !isAtLastRecord,
|
||||
filter: mergedFilter,
|
||||
orderBy,
|
||||
limit: isNonEmptyString(currentRecordCursorFromRequest) ? 1 : undefined,
|
||||
cursorFilter: isNonEmptyString(currentRecordCursorFromRequest)
|
||||
? {
|
||||
cursorDirection: 'before',
|
||||
cursor: currentRecordCursorFromRequest,
|
||||
}
|
||||
: undefined,
|
||||
objectNameSingular,
|
||||
recordGqlFields: { id: true },
|
||||
onCompleted: (_, pagination) => {
|
||||
setTotalCountBefore(pagination?.totalCount ?? 0);
|
||||
},
|
||||
});
|
||||
|
||||
const { loading: loadingRecordAfter, records: recordsAfter } =
|
||||
const { loading: loadingLastRecord, records: lastRecords } =
|
||||
useFindManyRecords({
|
||||
skip: loadingCursor,
|
||||
filter: {
|
||||
...filter,
|
||||
id: { neq: objectRecordId },
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
orderBy,
|
||||
limit: isNonEmptyString(currentRecordCursorFromRequest) ? 1 : undefined,
|
||||
cursorFilter: currentRecordCursorFromRequest
|
||||
? {
|
||||
cursorDirection: 'after',
|
||||
cursor: currentRecordCursorFromRequest,
|
||||
}
|
||||
: undefined,
|
||||
objectNameSingular,
|
||||
recordGqlFields: { id: true },
|
||||
onCompleted: (_, pagination) => {
|
||||
setTotalCountAfter(pagination?.totalCount ?? 0);
|
||||
},
|
||||
...baseNeighborOptions,
|
||||
skip: skipNeighborQueries || !isAtFirstRecord,
|
||||
filter: mergedFilter,
|
||||
orderBy: reversedOrderBy,
|
||||
});
|
||||
|
||||
const loading = loadingRecordAfter || loadingRecordBefore || loadingCursor;
|
||||
const loading =
|
||||
loadingRecordAfter ||
|
||||
loadingRecordBefore ||
|
||||
loadingCurrentRecord ||
|
||||
!hasKeysetFilters ||
|
||||
(isAtLastRecord && loadingFirstRecord) ||
|
||||
(isAtFirstRecord && loadingLastRecord);
|
||||
|
||||
const recordBefore = recordsBefore[0];
|
||||
const recordAfter = recordsAfter[0];
|
||||
|
||||
const isFirstRecord = !loading && !isDefined(recordBefore);
|
||||
const isLastRecord = !loading && !isDefined(recordAfter);
|
||||
|
||||
const { recordIdsInCache } = useRecordIdsFromFindManyCacheRootQuery({
|
||||
objectNamePlural: objectMetadataItem.namePlural,
|
||||
fieldVariables: {
|
||||
filter,
|
||||
orderBy,
|
||||
},
|
||||
});
|
||||
|
||||
const cacheIsAvailableForNavigation =
|
||||
!loading &&
|
||||
(totalCountAfter > 0 || totalCountBefore > 0) &&
|
||||
recordIdsInCache.length > 0;
|
||||
|
||||
const canNavigateToPreviousRecord =
|
||||
!isFirstRecord || (isFirstRecord && cacheIsAvailableForNavigation);
|
||||
// oxlint-disable-next-line twenty/no-navigate-prefer-link
|
||||
const navigateToRecord = (targetRecordId: string) => {
|
||||
navigate(
|
||||
AppPath.RecordShowPage,
|
||||
{ objectNameSingular, objectRecordId: targetRecordId },
|
||||
{ viewId: viewIdQueryParam },
|
||||
);
|
||||
};
|
||||
|
||||
const navigateToPreviousRecord = () => {
|
||||
if (loading) {
|
||||
return;
|
||||
if (loading) return;
|
||||
|
||||
if (isDefined(recordBefore)) {
|
||||
return navigateToRecord(recordBefore.id);
|
||||
}
|
||||
|
||||
if (isFirstRecord) {
|
||||
if (cacheIsAvailableForNavigation) {
|
||||
const lastRecordIdFromCache =
|
||||
recordIdsInCache[recordIdsInCache.length - 1];
|
||||
|
||||
navigate(
|
||||
AppPath.RecordShowPage,
|
||||
{
|
||||
objectNameSingular,
|
||||
objectRecordId: lastRecordIdFromCache,
|
||||
},
|
||||
{
|
||||
viewId: viewIdQueryParam,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
navigate(
|
||||
AppPath.RecordShowPage,
|
||||
{
|
||||
objectNameSingular,
|
||||
objectRecordId: recordBefore.id,
|
||||
},
|
||||
{
|
||||
viewId: viewIdQueryParam,
|
||||
},
|
||||
);
|
||||
if (isDefined(lastRecords[0])) {
|
||||
return navigateToRecord(lastRecords[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const canNavigateToNextRecord =
|
||||
!isLastRecord || (isLastRecord && cacheIsAvailableForNavigation);
|
||||
|
||||
const navigateToNextRecord = () => {
|
||||
if (loading) {
|
||||
return;
|
||||
if (loading) return;
|
||||
|
||||
if (isDefined(recordAfter)) {
|
||||
return navigateToRecord(recordAfter.id);
|
||||
}
|
||||
|
||||
if (isLastRecord) {
|
||||
if (cacheIsAvailableForNavigation) {
|
||||
const firstRecordIdFromCache = recordIdsInCache[0];
|
||||
|
||||
navigate(
|
||||
AppPath.RecordShowPage,
|
||||
{
|
||||
objectNameSingular,
|
||||
objectRecordId: firstRecordIdFromCache,
|
||||
},
|
||||
{
|
||||
viewId: viewIdQueryParam,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
navigate(
|
||||
AppPath.RecordShowPage,
|
||||
{
|
||||
objectNameSingular,
|
||||
objectRecordId: recordAfter.id,
|
||||
},
|
||||
{
|
||||
viewId: viewIdQueryParam,
|
||||
},
|
||||
);
|
||||
if (isDefined(firstRecords[0])) {
|
||||
return navigateToRecord(firstRecords[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToIndexView = () => {
|
||||
navigate(
|
||||
AppPath.RecordIndexPage,
|
||||
{
|
||||
objectNamePlural: objectMetadataItem.namePlural,
|
||||
},
|
||||
{
|
||||
viewId: viewIdQueryParam,
|
||||
},
|
||||
{ objectNamePlural: objectMetadataItem.namePlural },
|
||||
{ viewId: viewIdQueryParam },
|
||||
);
|
||||
|
||||
setLastShowPageRecordId(objectRecordId);
|
||||
};
|
||||
|
||||
const rankInView = recordIdsInCache.findIndex((id) => id === objectRecordId);
|
||||
const rankInView = isDefined(totalCountBefore) ? totalCountBefore : -1;
|
||||
const totalCount =
|
||||
rankInView > -1 && isDefined(totalCountAfter)
|
||||
? 1 + rankInView + totalCountAfter
|
||||
: 0;
|
||||
|
||||
const rankFoundInView = rankInView > -1;
|
||||
const [cachedPagination, setCachedPagination] = useState({
|
||||
rankInView,
|
||||
totalCount,
|
||||
});
|
||||
|
||||
const objectLabelPlural = objectMetadataItem.labelPlural;
|
||||
|
||||
const totalCount = 1 + Math.max(totalCountBefore, totalCountAfter);
|
||||
|
||||
const currentRank = rankInView + 1;
|
||||
const viewNameWithCount = rankFoundInView
|
||||
? t`${currentRank} of ${totalCount} in ${objectLabelPlural}`
|
||||
: t`${objectLabelPlural} (${totalCount})`;
|
||||
if (!loading && rankInView > -1) {
|
||||
if (
|
||||
cachedPagination.rankInView !== rankInView ||
|
||||
cachedPagination.totalCount !== totalCount
|
||||
) {
|
||||
setCachedPagination({ rankInView, totalCount });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
viewName: viewNameWithCount,
|
||||
isLoadingPagination: loading,
|
||||
navigateToPreviousRecord,
|
||||
navigateToNextRecord,
|
||||
navigateToIndexView,
|
||||
canNavigateToNextRecord,
|
||||
canNavigateToPreviousRecord,
|
||||
rankInView,
|
||||
totalCount,
|
||||
rankInView: loading ? cachedPagination.rankInView : rankInView,
|
||||
totalCount: loading ? cachedPagination.totalCount : totalCount,
|
||||
objectMetadataItem,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { isRecordFilterAboutSoftDelete } from '@/object-record/record-filter/utils/isRecordFilterAboutSoftDelete';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { getQueryVariablesFromFiltersAndSorts } from '@/views/utils/getQueryVariablesFromFiltersAndSorts';
|
||||
@@ -37,8 +38,14 @@ export const useQueryVariablesFromParentView = ({
|
||||
filterValueDependencies,
|
||||
});
|
||||
|
||||
const isSoftDeleteFilterActive =
|
||||
contextStoreRecordShowParentView?.parentViewFilters.some((recordFilter) =>
|
||||
isRecordFilterAboutSoftDelete({ recordFilter, objectMetadataItems }),
|
||||
) ?? false;
|
||||
|
||||
return {
|
||||
filter,
|
||||
orderBy,
|
||||
isSoftDeleteFilterActive,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@ export type IsFilter = 'NULL' | 'NOT_NULL';
|
||||
|
||||
export type UUIDFilter = {
|
||||
eq?: UUIDFilterValue;
|
||||
gt?: UUIDFilterValue;
|
||||
gte?: UUIDFilterValue;
|
||||
in?: UUIDFilterValue[];
|
||||
lt?: UUIDFilterValue;
|
||||
lte?: UUIDFilterValue;
|
||||
neq?: UUIDFilterValue;
|
||||
is?: IsFilter;
|
||||
};
|
||||
@@ -21,7 +25,11 @@ export type BooleanFilter = {
|
||||
|
||||
export type StringFilter = {
|
||||
eq?: string;
|
||||
gt?: string;
|
||||
gte?: string;
|
||||
in?: string[];
|
||||
lt?: string;
|
||||
lte?: string;
|
||||
neq?: string;
|
||||
startsWith?: string;
|
||||
like?: string;
|
||||
|
||||
@@ -15,6 +15,18 @@ export const isMatchingStringFilter = ({
|
||||
case stringFilter.neq !== undefined: {
|
||||
return value !== stringFilter.neq;
|
||||
}
|
||||
case stringFilter.gt !== undefined: {
|
||||
return value > stringFilter.gt;
|
||||
}
|
||||
case stringFilter.gte !== undefined: {
|
||||
return value >= stringFilter.gte;
|
||||
}
|
||||
case stringFilter.lt !== undefined: {
|
||||
return value < stringFilter.lt;
|
||||
}
|
||||
case stringFilter.lte !== undefined: {
|
||||
return value <= stringFilter.lte;
|
||||
}
|
||||
case stringFilter.like !== undefined: {
|
||||
const escapedPattern = escapeRegExp(stringFilter.like);
|
||||
const regexPattern = escapedPattern.replace(/%/g, '.*');
|
||||
|
||||
@@ -14,6 +14,18 @@ export const isMatchingUUIDFilter = ({
|
||||
case uuidFilter.neq !== undefined: {
|
||||
return value !== uuidFilter.neq;
|
||||
}
|
||||
case uuidFilter.gt !== undefined: {
|
||||
return value > uuidFilter.gt;
|
||||
}
|
||||
case uuidFilter.gte !== undefined: {
|
||||
return value >= uuidFilter.gte;
|
||||
}
|
||||
case uuidFilter.lt !== undefined: {
|
||||
return value < uuidFilter.lt;
|
||||
}
|
||||
case uuidFilter.lte !== undefined: {
|
||||
return value <= uuidFilter.lte;
|
||||
}
|
||||
case uuidFilter.in !== undefined: {
|
||||
return uuidFilter.in.includes(value);
|
||||
}
|
||||
@@ -26,7 +38,7 @@ export const isMatchingUUIDFilter = ({
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unexpected value for string filter : ${JSON.stringify(uuidFilter)}`,
|
||||
`Unexpected value for UUID filter: ${JSON.stringify(uuidFilter)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user