Skip to content

Commit 514ce90

Browse files
[sync] fix(app): preserve view defaults for personal selection requests (#1423) (#2749)
Synced from teableio/teable-ee@acd33a7 Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent d1f70a0 commit 514ce90

4 files changed

Lines changed: 164 additions & 6 deletions

File tree

apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { useConfirm } from '@teable/ui-lib/base';
3434
import { toast } from '@teable/ui-lib/shadcn/ui/sonner';
3535
import type { AxiosResponse } from 'axios';
3636
import { useTranslation } from 'next-i18next';
37-
import { useCallback } from 'react';
37+
import { useCallback, useMemo } from 'react';
3838
import { isHTTPS, isLocalhost } from '@/features/app/utils';
3939
import { serializerCellValueHtml, serializerHtml } from '@/features/app/utils/clipboard';
4040
import { tableConfig } from '@/features/i18n/table.config';
@@ -48,6 +48,7 @@ import {
4848
textPasteHandlerWithData,
4949
} from '../utils/copyAndPaste';
5050
import { getSyncCopyData } from '../utils/getSyncCopyData';
51+
import { buildSelectionViewQuery } from '../utils/selectionViewQuery';
5152
import { useSyncSelectionStore } from './useSelectionStore';
5253

5354
const clearToastId = 'clearToastId';
@@ -79,13 +80,17 @@ export const useSelectionOperation = (props?: {
7980
const { t } = useTranslation(tableConfig.i18nNamespaces);
8081

8182
const groupBy = view?.group;
83+
const selectionViewQuery = useMemo(
84+
() => buildSelectionViewQuery({ view, personalViewCommonQuery }),
85+
[view, personalViewCommonQuery]
86+
);
8287

8388
const { mutateAsync: defaultCopyReq } = useMutation({
8489
mutationFn: async (copyRo: IRangesRo) => {
8590
const { collapsedGroupIds: _originalCollapsedGroupIds, ...rest } = copyRo;
8691
const params = {
8792
...rest,
88-
...personalViewCommonQuery,
93+
...selectionViewQuery,
8994
viewId,
9095
groupBy,
9196
search,
@@ -105,7 +110,7 @@ export const useSelectionOperation = (props?: {
105110
mutationFn: (pasteRo: IPasteRo) =>
106111
paste(tableId!, {
107112
...pasteRo,
108-
...personalViewCommonQuery,
113+
...selectionViewQuery,
109114
viewId,
110115
groupBy,
111116
collapsedGroupIds,
@@ -118,14 +123,14 @@ export const useSelectionOperation = (props?: {
118123

119124
const { mutateAsync: temporaryPasteReq } = useMutation({
120125
mutationFn: (temporaryPasteRo: ITemporaryPasteRo) =>
121-
temporaryPaste(tableId!, { ...temporaryPasteRo, ...personalViewCommonQuery, viewId }),
126+
temporaryPaste(tableId!, { ...temporaryPasteRo, ...selectionViewQuery, viewId }),
122127
});
123128

124129
const { mutateAsync: clearReq } = useMutation({
125130
mutationFn: (clearRo: IRangesRo) =>
126131
clear(tableId!, {
127132
...clearRo,
128-
...personalViewCommonQuery,
133+
...selectionViewQuery,
129134
viewId,
130135
groupBy,
131136
collapsedGroupIds,
@@ -141,7 +146,7 @@ export const useSelectionOperation = (props?: {
141146
const { collapsedGroupIds: _originalCollapsedGroupIds, ...rest } = deleteRo;
142147
const params = {
143148
...rest,
144-
...personalViewCommonQuery,
149+
...selectionViewQuery,
145150
viewId,
146151
groupBy,
147152
search,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { IGetRecordsRo } from '@teable/openapi';
2+
import { describe, expect, it } from 'vitest';
3+
import { buildSelectionViewQuery } from './selectionViewQuery';
4+
5+
describe('buildSelectionViewQuery', () => {
6+
it('returns undefined when there is no personal view query', () => {
7+
expect(buildSelectionViewQuery({})).toBeUndefined();
8+
});
9+
10+
it('drops ignoreViewQuery when personal query matches saved view query', () => {
11+
const filter: NonNullable<IGetRecordsRo['filter']> = {
12+
conjunction: 'and',
13+
filterSet: [{ fieldId: 'fldValue', operator: 'is', value: 'Open' }],
14+
};
15+
const orderBy: NonNullable<IGetRecordsRo['orderBy']> = [{ fieldId: 'fldSort', order: 'desc' }];
16+
const groupBy: NonNullable<IGetRecordsRo['groupBy']> = [{ fieldId: 'fldGroup', order: 'asc' }];
17+
18+
expect(
19+
buildSelectionViewQuery({
20+
view: {
21+
filter,
22+
sort: { sortObjs: orderBy },
23+
group: groupBy,
24+
},
25+
personalViewCommonQuery: {
26+
ignoreViewQuery: true,
27+
filter,
28+
orderBy,
29+
groupBy,
30+
projection: ['fldPrimary'],
31+
},
32+
})
33+
).toEqual({
34+
projection: ['fldPrimary'],
35+
});
36+
});
37+
38+
it('keeps ignoreViewQuery when personal query intentionally clears a saved filter', () => {
39+
const filter: NonNullable<IGetRecordsRo['filter']> = {
40+
conjunction: 'and',
41+
filterSet: [{ fieldId: 'fldValue', operator: 'is', value: 'Open' }],
42+
};
43+
44+
expect(
45+
buildSelectionViewQuery({
46+
view: {
47+
filter,
48+
},
49+
personalViewCommonQuery: {
50+
ignoreViewQuery: true,
51+
filter: null,
52+
projection: ['fldPrimary'],
53+
},
54+
})
55+
).toEqual({
56+
ignoreViewQuery: true,
57+
filter: null,
58+
projection: ['fldPrimary'],
59+
});
60+
});
61+
62+
it('keeps ignoreViewQuery when personal query changes sorting', () => {
63+
const orderBy: NonNullable<IGetRecordsRo['orderBy']> = [{ fieldId: 'fldSort', order: 'asc' }];
64+
65+
expect(
66+
buildSelectionViewQuery({
67+
view: {
68+
sort: { sortObjs: [{ fieldId: 'fldSort', order: 'desc' }] },
69+
},
70+
personalViewCommonQuery: {
71+
ignoreViewQuery: true,
72+
orderBy,
73+
projection: ['fldPrimary'],
74+
},
75+
})
76+
).toEqual({
77+
ignoreViewQuery: true,
78+
orderBy,
79+
projection: ['fldPrimary'],
80+
});
81+
});
82+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { IGetRecordsRo } from '@teable/openapi';
2+
import { isEqual } from 'lodash';
3+
4+
type IViewQueryLike = {
5+
filter?: IGetRecordsRo['filter'];
6+
sort?: { sortObjs?: IGetRecordsRo['orderBy'] } | null;
7+
group?: IGetRecordsRo['groupBy'];
8+
};
9+
10+
type ISelectionViewQuery = Pick<
11+
IGetRecordsRo,
12+
'ignoreViewQuery' | 'filter' | 'orderBy' | 'groupBy' | 'projection'
13+
>;
14+
15+
/**
16+
* Personal views always carry ignoreViewQuery=true, but selection APIs only need that
17+
* flag when the personal view actually changes row-targeting query state.
18+
*/
19+
export const buildSelectionViewQuery = ({
20+
view,
21+
personalViewCommonQuery,
22+
}: {
23+
view?: IViewQueryLike;
24+
personalViewCommonQuery?: ISelectionViewQuery;
25+
}): ISelectionViewQuery | undefined => {
26+
if (!personalViewCommonQuery) {
27+
return;
28+
}
29+
30+
const { ignoreViewQuery, filter, orderBy, groupBy, projection } = personalViewCommonQuery;
31+
if (!ignoreViewQuery) {
32+
return personalViewCommonQuery;
33+
}
34+
35+
const hasQueryDifference =
36+
!isEqual(filter ?? null, view?.filter ?? null) ||
37+
!isEqual(orderBy, view?.sort?.sortObjs) ||
38+
!isEqual(groupBy, view?.group);
39+
40+
if (hasQueryDifference) {
41+
return personalViewCommonQuery;
42+
}
43+
44+
return projection ? { projection } : undefined;
45+
};

packages/v2/e2e/src/paste.e2e.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3657,6 +3657,32 @@ describe('v2 http paste (e2e)', () => {
36573657
// B should be updated (last in DESC among filtered)
36583658
expect(recordB?.fields[vsSortNameFieldId]).toBe('ViewSortFilterRow3');
36593659
});
3660+
3661+
it('should paste to the correct row when client only sends projection', async () => {
3662+
// Simulate the personal-view request shape after frontend normalization:
3663+
// projection can differ, but row-targeting should still follow saved view defaults.
3664+
const result = await ctx.paste({
3665+
tableId: vsSortTableId,
3666+
viewId: vsSortViewId,
3667+
ranges: [
3668+
[0, 1],
3669+
[0, 1],
3670+
],
3671+
content: [['ProjectionOnlyRow1']],
3672+
projection: [vsSortNameFieldId],
3673+
});
3674+
3675+
expect(result.updatedCount).toBe(1);
3676+
3677+
const records = await ctx.listRecords(vsSortTableId);
3678+
const recordD = records.find((r) => r.fields[vsSortValueFieldId] === 400);
3679+
const recordE = records.find((r) => r.fields[vsSortValueFieldId] === 500);
3680+
3681+
// Filtered DESC: E(500), D(400), C(300), B(200)
3682+
// Row 1 should update D, even when projection is present.
3683+
expect(recordD?.fields[vsSortNameFieldId]).toBe('ProjectionOnlyRow1');
3684+
expect(recordE?.fields[vsSortNameFieldId]).toBe('RecordE');
3685+
});
36603686
});
36613687

36623688
describe('paste with NULL values in sort field (v1 null ordering alignment)', () => {

0 commit comments

Comments
 (0)