Skip to content

Commit e1eb9f7

Browse files
tea-artistteable-bot
andauthored
[sync] fix(t1557): export personal view losing view condition (#986) (#2395)
Synced from teableio/teable-ee@3f3fe02 Co-authored-by: teable-bot <bot@teable.io>
1 parent 12270ae commit e1eb9f7

File tree

18 files changed

+397
-106
lines changed

18 files changed

+397
-106
lines changed

apps/nestjs-backend/src/features/export/open-api/export-open-api.controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Controller, Get, UseGuards, Param, Res, Query } from '@nestjs/common';
2+
import { type IExportCsvRo, exportCsvRoSchema } from '@teable/openapi';
23
import { Response } from 'express';
4+
import { ZodValidationPipe } from '../../../zod.validation.pipe';
35
import { Permissions } from '../../auth/decorators/permissions.decorator';
46
import { PermissionGuard } from '../../auth/guard/permission.guard';
57
import { ExportOpenApiService } from './export-open-api.service';
@@ -12,9 +14,9 @@ export class ExportOpenApiController {
1214
@Permissions('table|export', 'view|read')
1315
async exportCsvFromTable(
1416
@Param('tableId') tableId: string,
15-
@Query('viewId') viewId: string,
17+
@Query(new ZodValidationPipe(exportCsvRoSchema)) query: IExportCsvRo,
1618
@Res({ passthrough: true }) response: Response
1719
): Promise<void> {
18-
return await this.exportOpenService.exportCsvFromTable(response, tableId, viewId);
20+
return await this.exportOpenService.exportCsvFromTable(response, tableId, query);
1921
}
2022
}

apps/nestjs-backend/src/features/export/open-api/export-open-api.service.ts

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Readable } from 'stream';
22
import { Injectable, Logger } from '@nestjs/common';
3-
import type { IAttachmentCellValue, IFilter } from '@teable/core';
4-
import { FieldType, HttpErrorCode, mergeFilter, ViewType } from '@teable/core';
3+
import type { IAttachmentCellValue, IFieldVo } from '@teable/core';
4+
import { FieldType, HttpErrorCode, ViewType } from '@teable/core';
55
import { PrismaService } from '@teable/db-main-prisma';
6+
import type { IExportCsvRo } from '@teable/openapi';
67
import type { Response } from 'express';
8+
import { keyBy, sortBy } from 'lodash';
79
import Papa from 'papaparse';
810
import { CustomHttpException } from '../../../custom.exception';
911
import { FieldService } from '../../field/field.service';
@@ -18,15 +20,16 @@ export class ExportOpenApiService {
1820
private readonly recordService: RecordService,
1921
private readonly prismaService: PrismaService
2022
) {}
21-
async exportCsvFromTable(
22-
response: Response,
23-
tableId: string,
24-
viewId?: string,
25-
exportQuery?: {
26-
projection?: string[];
27-
recordFilter?: IFilter;
28-
}
29-
) {
23+
async exportCsvFromTable(response: Response, tableId: string, query?: IExportCsvRo) {
24+
const {
25+
viewId,
26+
filter: queryFilter,
27+
orderBy: queryOrderBy,
28+
groupBy: queryGroupBy,
29+
projection,
30+
ignoreViewQuery,
31+
columnMeta: queryColumnMeta,
32+
} = query ?? {};
3033
let count = 0;
3134
let isOver = false;
3235
const csvStream = new Readable({
@@ -48,7 +51,7 @@ export class ExportOpenApiService {
4851
});
4952
});
5053

51-
if (viewId) {
54+
if (viewId && !ignoreViewQuery) {
5255
viewRaw = await this.prismaService.view
5356
.findUnique({
5457
where: {
@@ -57,10 +60,9 @@ export class ExportOpenApiService {
5760
deletedTime: null,
5861
},
5962
select: {
60-
name: true,
6163
id: true,
6264
type: true,
63-
filter: true,
65+
name: true,
6466
},
6567
})
6668
.catch((e) => {
@@ -93,20 +95,27 @@ export class ExportOpenApiService {
9395
csvStream.pipe(response);
9496

9597
// set headers as first row
96-
const headers = (
97-
await this.fieldService.getFieldsByQuery(tableId, {
98-
viewId: viewRaw?.id ? viewRaw?.id : undefined,
99-
filterHidden: viewRaw?.id ? true : undefined,
100-
})
101-
).filter((field) => {
102-
if (exportQuery?.projection) {
103-
return exportQuery?.projection.includes(field.id);
104-
}
105-
106-
return true;
98+
const viewIdForQuery = ignoreViewQuery ? undefined : viewRaw?.id;
99+
let allFields = await this.fieldService.getFieldsByQuery(tableId, {
100+
viewId: viewIdForQuery,
101+
filterHidden: Boolean(viewIdForQuery),
107102
});
103+
104+
// Sort fields based on:
105+
// 1. If ignoreViewQuery is true and queryColumnMeta is provided, sort by queryColumnMeta order
106+
// 2. If viewId is provided (and ignoreViewQuery is false), getFieldsByQuery already sorted by view columnMeta
107+
// 3. Otherwise, keep table's original field order
108+
allFields = this.sortFieldsByColumnMeta(allFields, ignoreViewQuery, queryColumnMeta);
109+
110+
const fieldsMap = keyBy(allFields, 'id');
111+
// Filter by projection but keep the original field order from view/table
112+
const headers = allFields.filter((field) => !projection || projection.includes(field.id));
108113
const headerData = Papa.unparse([headers.map((h) => h.name)]);
109114

115+
const projectionNames = projection
116+
? (projection.map((p) => fieldsMap[p]?.name).filter((p) => Boolean(p)) as string[])
117+
: undefined;
118+
110119
const headersInfoMap = new Map(
111120
headers.map((h, index) => [
112121
h.name,
@@ -122,22 +131,23 @@ export class ExportOpenApiService {
122131
csvStream.push('\uFEFF');
123132
csvStream.push(headerData);
124133

125-
const mergedFilter = viewRaw?.filter
126-
? mergeFilter(JSON.parse(viewRaw?.filter), exportQuery?.recordFilter)
127-
: exportQuery?.recordFilter;
128-
129134
try {
130135
while (!isOver) {
131136
const { records } = await this.recordService.getRecords(
132137
tableId,
133138
{
134139
take: 1000,
135140
skip: count,
136-
viewId: viewRaw?.id ? viewRaw?.id : undefined,
137-
filter: mergedFilter,
141+
viewId: viewIdForQuery,
142+
filter: queryFilter,
143+
orderBy: queryOrderBy,
144+
groupBy: queryGroupBy,
145+
ignoreViewQuery,
146+
projection: projectionNames,
138147
},
139148
true
140149
);
150+
141151
if (records.length === 0) {
142152
isOver = true;
143153
// end the stream
@@ -164,6 +174,7 @@ export class ExportOpenApiService {
164174
return recordsArr;
165175
})
166176
);
177+
167178
csvStream.push('\r\n');
168179
csvStream.push(csvData);
169180
count += records.length;
@@ -174,4 +185,24 @@ export class ExportOpenApiService {
174185
this.logger.error((e as Error)?.message, `ExportCsv: ${tableId}`);
175186
}
176187
}
188+
189+
/**
190+
* Sort fields based on columnMeta order
191+
* @param fields - The fields to sort
192+
* @param ignoreViewQuery - Whether to ignore view query
193+
* @param queryColumnMeta - The columnMeta from query params for custom sorting
194+
* @returns Sorted fields
195+
*/
196+
private sortFieldsByColumnMeta(
197+
fields: IFieldVo[],
198+
ignoreViewQuery?: boolean,
199+
queryColumnMeta?: Record<string, { order: number }>
200+
): IFieldVo[] {
201+
// If ignoreViewQuery is true and queryColumnMeta is provided, sort by queryColumnMeta order
202+
if (ignoreViewQuery && queryColumnMeta) {
203+
return sortBy(fields, (field) => queryColumnMeta[field.id]?.order ?? Infinity);
204+
}
205+
// Otherwise, keep the order from getFieldsByQuery (either view columnMeta order or table original order)
206+
return fields;
207+
}
177208
}

apps/nestjs-backend/test/table-export.e2e-spec.ts

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs';
33
import path from 'path';
44
import type { INestApplication } from '@nestjs/common';
55
import type { IFieldVo, IViewRo } from '@teable/core';
6-
import { FieldType, Colors, Relationship, ViewType, DriverClient } from '@teable/core';
6+
import { FieldType, Colors, Relationship, ViewType, DriverClient, SortFunc } from '@teable/core';
77
import type { INotifyVo } from '@teable/openapi';
88
import {
99
exportCsvFromTable as apiExportCsvFromTable,
@@ -315,7 +315,7 @@ describe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(
315315
type: ViewType.Grid,
316316
});
317317

318-
const exportRes = await apiExportCsvFromTable(mainTable.id, view2.id);
318+
const exportRes = await apiExportCsvFromTable(mainTable.id, { viewId: view2.id });
319319
const { data: csvData } = exportRes;
320320

321321
await apiDeleteTable(baseId, mainTable.id);
@@ -345,7 +345,7 @@ describe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(
345345
type: ViewType.Grid,
346346
});
347347

348-
const exportRes = await apiExportCsvFromTable(mainTable.id, view2.id);
348+
const exportRes = await apiExportCsvFromTable(mainTable.id, { viewId: view2.id });
349349
const { data: csvData } = exportRes;
350350

351351
await apiDeleteTable(baseId, mainTable.id);
@@ -355,5 +355,149 @@ describe.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(
355355
`Text field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\r\ntxt1,true,x,"November 28, 2022",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y\r\ntxt2,,y,"November 28, 2022",,test,,,,,\r\n,true,z,,,,,,,,`
356356
);
357357
});
358+
359+
it(`should return a csv stream with filter parameter (personal view filter)`, async () => {
360+
const { mainTable, subTable } = await createTables();
361+
362+
const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;
363+
364+
// Export with filter to only include records where Text field = 'txt1'
365+
const exportRes = await apiExportCsvFromTable(mainTable.id, {
366+
filter: {
367+
conjunction: 'and',
368+
filterSet: [
369+
{
370+
fieldId: textField.id,
371+
operator: 'is',
372+
value: 'txt1',
373+
},
374+
],
375+
},
376+
});
377+
const { data: csvData } = exportRes;
378+
379+
await apiDeleteTable(baseId, mainTable.id);
380+
await apiDeleteTable(baseId, subTable.id);
381+
382+
// Should only contain the first record with txt1
383+
expect(csvData).toBe(
384+
`Text field,Number field,Checkbox field,Select field,Date field,Attachment field,User Field,Link field,Link field from lookups sub_Name,Link field from lookups sub_Number,Link field from lookups sub_Checkbox,Link field from lookups sub_SingleSelect\r\ntxt1,1.00,true,x,"November 28, 2022",test.txt ${txtFileData.presignedUrl},,Name1,Name1,1.00,true,sub_y`
385+
);
386+
});
387+
388+
it(`should return a csv stream with projection parameter (only specified fields)`, async () => {
389+
const { mainTable, subTable } = await createTables();
390+
391+
const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;
392+
const numberField = mainTable?.fields?.find((f) => f.name === 'Number field') as IFieldVo;
393+
const selectField = mainTable?.fields?.find((f) => f.name === 'Select field') as IFieldVo;
394+
395+
// Export with projection to only include specific fields
396+
const exportRes = await apiExportCsvFromTable(mainTable.id, {
397+
projection: [textField.id, numberField.id, selectField.id],
398+
});
399+
const { data: csvData } = exportRes;
400+
401+
await apiDeleteTable(baseId, mainTable.id);
402+
await apiDeleteTable(baseId, subTable.id);
403+
404+
// Should only contain the specified fields in projection order
405+
expect(csvData).toBe(`Text field,Number field,Select field\r\ntxt1,1.00,x\r\ntxt2,,y\r\n,,z`);
406+
});
407+
408+
it(`should return a csv stream with orderBy parameter (sorted export)`, async () => {
409+
const { mainTable, subTable } = await createTables();
410+
411+
const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;
412+
413+
// Export with orderBy to sort by Text field descending
414+
const exportRes = await apiExportCsvFromTable(mainTable.id, {
415+
orderBy: [
416+
{
417+
fieldId: textField.id,
418+
order: SortFunc.Desc,
419+
},
420+
],
421+
projection: [textField.id], // Use projection to simplify test assertion
422+
});
423+
const { data: csvData } = exportRes;
424+
425+
await apiDeleteTable(baseId, mainTable.id);
426+
await apiDeleteTable(baseId, subTable.id);
427+
428+
// Records should be sorted: txt2, txt1, empty
429+
expect(csvData).toBe(`Text field\r\ntxt2\r\ntxt1\r\n`);
430+
});
431+
432+
it(`should return a csv stream with ignoreViewQuery parameter (ignore view filter)`, async () => {
433+
const { mainTable, subTable } = await createTables();
434+
435+
const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;
436+
437+
// Create a view with filter
438+
const viewWithFilter = await createView(mainTable.id, {
439+
type: ViewType.Grid,
440+
filter: {
441+
conjunction: 'and',
442+
filterSet: [
443+
{
444+
fieldId: textField.id,
445+
operator: 'is',
446+
value: 'txt1',
447+
},
448+
],
449+
},
450+
});
451+
452+
// Export with ignoreViewQuery=true should return all records despite view filter
453+
const exportRes = await apiExportCsvFromTable(mainTable.id, {
454+
viewId: viewWithFilter.id,
455+
ignoreViewQuery: true,
456+
projection: [textField.id],
457+
});
458+
const { data: csvData } = exportRes;
459+
460+
await apiDeleteTable(baseId, mainTable.id);
461+
await apiDeleteTable(baseId, subTable.id);
462+
463+
// Should return all records since view query is ignored
464+
expect(csvData).toBe(`Text field\r\ntxt1\r\ntxt2\r\n`);
465+
});
466+
467+
it(`should return a csv stream with combined filter and projection (personal view scenario)`, async () => {
468+
const { mainTable, subTable } = await createTables();
469+
470+
const textField = mainTable?.fields?.find((f) => f.name === 'Text field') as IFieldVo;
471+
const selectField = mainTable?.fields?.find((f) => f.name === 'Select field') as IFieldVo;
472+
const numberField = mainTable?.fields?.find((f) => f.name === 'Number field') as IFieldVo;
473+
474+
// Simulate personal view export with filter + projection + orderBy
475+
const exportRes = await apiExportCsvFromTable(mainTable.id, {
476+
filter: {
477+
conjunction: 'and',
478+
filterSet: [
479+
{
480+
fieldId: selectField.id,
481+
operator: 'isAnyOf',
482+
value: ['x', 'y'],
483+
},
484+
],
485+
},
486+
projection: [textField.id, numberField.id, selectField.id],
487+
orderBy: [
488+
{
489+
fieldId: textField.id,
490+
order: SortFunc.Asc,
491+
},
492+
],
493+
});
494+
const { data: csvData } = exportRes;
495+
496+
await apiDeleteTable(baseId, mainTable.id);
497+
await apiDeleteTable(baseId, subTable.id);
498+
499+
// Should only return records with select 'x' or 'y', sorted by text field ascending
500+
expect(csvData).toBe(`Text field,Number field,Select field\r\ntxt1,1.00,x\r\ntxt2,,y`);
501+
});
358502
}
359503
);

apps/nextjs-app/src/features/app/blocks/admin/template/components/BaseSelectPanel.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,19 @@ export const BaseSelectPanel = (props: IBaseSelectPanelProps) => {
8787
<Button
8888
variant="outline"
8989
size={'xs'}
90-
className={cn('w-32 overflow-hidden', {
90+
className={cn('w-32 overflow-hidden truncate', {
9191
'border-red-500': !baseName,
9292
})}
9393
disabled={disabled}
9494
>
95-
{baseName ?? (
96-
<span
97-
className="truncate text-red-500"
98-
title={t('settings.templateAdmin.baseSelectPanel.abnormalBase')}
99-
>
100-
{t('settings.templateAdmin.baseSelectPanel.abnormalBase')}
101-
</span>
102-
)}
95+
<span
96+
className={cn('truncate', {
97+
'text-red-500': !baseName,
98+
})}
99+
title={baseName ?? t('settings.templateAdmin.baseSelectPanel.abnormalBase')}
100+
>
101+
{baseName ?? t('settings.templateAdmin.baseSelectPanel.abnormalBase')}
102+
</span>
103103
</Button>
104104
</DialogTrigger>
105105
<DialogContent className="flex h-[550px] min-w-[750px] flex-col">

0 commit comments

Comments
 (0)