feat(editor): CSV download support for data tables (#22048)

This commit is contained in:
Nikhil Kuriakose 2025-11-21 12:59:20 +01:00 committed by GitHub
parent 94505bfad4
commit 81a3d395f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 613 additions and 0 deletions

View File

@ -0,0 +1,391 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
createTeamProject,
getPersonalProject,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { createDataTable } from '@test-integration/db/data-tables';
import { createOwner, createMember } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import * as utils from '@test-integration/utils';
import { DataTableColumnRepository } from '../data-table-column.repository';
import { DataTableRowsRepository } from '../data-table-rows.repository';
import { mockDataTableSizeValidator } from './test-helpers';
let owner: User;
let member: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
let ownerProject: Project;
let memberProject: Project;
const testServer = utils.setupTestServer({
endpointGroups: ['data-table'],
modules: ['data-table'],
});
let dataTableColumnRepository: DataTableColumnRepository;
let dataTableRowsRepository: DataTableRowsRepository;
beforeAll(async () => {
mockDataTableSizeValidator();
dataTableColumnRepository = Container.get(DataTableColumnRepository);
dataTableRowsRepository = Container.get(DataTableRowsRepository);
owner = await createOwner();
member = await createMember();
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
ownerProject = await getPersonalProject(owner);
memberProject = await getPersonalProject(member);
});
beforeEach(async () => {
await testDb.truncate(['DataTable', 'DataTableColumn']);
});
describe('GET /projects/:projectId/data-tables/:dataTableId/download-csv', () => {
test('should not download CSV when project does not exist', async () => {
await authOwnerAgent
.get('/projects/non-existing-id/data-tables/some-data-table-id/download-csv')
.expect(404);
});
test('should not download CSV when data table does not exist', async () => {
const project = await createTeamProject('test project', owner);
await authOwnerAgent
.get(`/projects/${project.id}/data-tables/non-existing-id/download-csv`)
.expect(404);
});
test('should not download CSV if user has no access to project', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Test Data Table',
columns: [{ name: 'test_column', type: 'string' }],
});
await authMemberAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(403);
});
test("should not download CSV from another user's personal project", async () => {
const dataTable = await createDataTable(ownerProject, {
name: 'Personal Data Table',
columns: [{ name: 'test_column', type: 'string' }],
});
await authMemberAgent
.get(`/projects/${ownerProject.id}/data-tables/${dataTable.id}/download-csv`)
.expect(403);
});
test('should download CSV with headers only for empty table', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Empty Table',
columns: [
{ name: 'firstName', type: 'string' },
{ name: 'age', type: 'number' },
],
});
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
expect(response.body.data.dataTableName).toBe('Empty Table');
expect(response.body.data.csvContent).toBe('id,firstName,age,createdAt,updatedAt');
});
test('should download CSV with data rows', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'People',
columns: [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'number' },
],
});
// Get columns for insertRows
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
// Insert rows
await dataTableRowsRepository.insertRows(
dataTable.id,
[
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
],
columns,
'id',
);
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
expect(response.body.data.dataTableName).toBe('People');
const csvContent = response.body.data.csvContent;
const lines = csvContent.split('\n');
// Check header
expect(lines[0]).toBe('id,name,age,createdAt,updatedAt');
// Check data rows exist
expect(lines.length).toBe(3); // header + 2 rows
expect(csvContent).toContain('Alice');
expect(csvContent).toContain('Bob');
expect(csvContent).toContain('30');
expect(csvContent).toContain('25');
});
test('should properly escape special characters in CSV', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Special Chars',
columns: [{ name: 'description', type: 'string' }],
});
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
// Insert rows with special characters
await dataTableRowsRepository.insertRows(
dataTable.id,
[
{ description: 'Contains "quotes"' },
{ description: 'Contains, commas' },
{ description: 'Contains\nnewlines' },
{ description: ' leading and trailing ' },
],
columns,
'id',
);
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
const csvContent = response.body.data.csvContent;
// Check proper escaping
expect(csvContent).toContain('"Contains ""quotes"""'); // Quotes doubled and wrapped
expect(csvContent).toContain('"Contains, commas"'); // Wrapped due to comma
expect(csvContent).toContain('"Contains\nnewlines"'); // Wrapped due to newline
expect(csvContent).toContain('" leading and trailing "'); // Wrapped due to spaces
});
test('should handle different data types in CSV', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Mixed Types',
columns: [
{ name: 'text', type: 'string' },
{ name: 'number', type: 'number' },
{ name: 'flag', type: 'boolean' },
{ name: 'timestamp', type: 'date' },
],
});
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
const testDate = new Date('2025-01-15T10:30:00.000Z');
await dataTableRowsRepository.insertRows(
dataTable.id,
[
{
text: 'hello',
number: 42,
flag: true,
timestamp: testDate,
},
],
columns,
'id',
);
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
const csvContent = response.body.data.csvContent;
const lines = csvContent.split('\n');
expect(lines[0]).toBe('id,text,number,flag,timestamp,createdAt,updatedAt');
expect(lines[1]).toContain('hello');
expect(lines[1]).toContain('42');
// Boolean values vary by database: SQLite/MySQL use 0/1, PostgreSQL uses true/false
expect(lines[1]).toMatch(/,(true|1),/);
// Check for date in ISO format (timezone may vary)
expect(lines[1]).toMatch(/2025-01-15T\d{2}:\d{2}:\d{2}\.\d{3}Z/);
});
test('should handle null values in CSV', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Nullable Fields',
columns: [
{ name: 'required', type: 'string' },
{ name: 'optional', type: 'string' },
],
});
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
await dataTableRowsRepository.insertRows(
dataTable.id,
[{ required: 'value', optional: null }],
columns,
'id',
);
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
const csvContent = response.body.data.csvContent;
const lines = csvContent.split('\n');
// Null should be represented as empty field
expect(lines[1]).toMatch(/,value,,/); // Empty field for null value
});
test('should download CSV with correct column order', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Ordered Columns',
columns: [
{ name: 'third', type: 'string' },
{ name: 'first', type: 'string' },
{ name: 'second', type: 'string' },
],
});
// Reorder columns
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
const thirdCol = columns.find((c) => c.name === 'third');
const firstCol = columns.find((c) => c.name === 'first');
const secondCol = columns.find((c) => c.name === 'second');
if (thirdCol && firstCol && secondCol) {
await dataTableColumnRepository.update(firstCol.id, { index: 0 });
await dataTableColumnRepository.update(secondCol.id, { index: 1 });
await dataTableColumnRepository.update(thirdCol.id, { index: 2 });
}
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
const csvContent = response.body.data.csvContent;
const lines = csvContent.split('\n');
// Columns should be ordered by index
expect(lines[0]).toBe('id,first,second,third,createdAt,updatedAt');
});
test('should allow CSV download if user has project:viewer role', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(member, project, 'project:viewer');
const dataTable = await createDataTable(project, {
name: 'Viewable Table',
columns: [{ name: 'data', type: 'string' }],
});
const response = await authMemberAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
expect(response.body.data.dataTableName).toBe('Viewable Table');
expect(response.body.data.csvContent).toContain('id,data,createdAt,updatedAt');
});
test('should allow CSV download if user has project:editor role', async () => {
const project = await createTeamProject('test project', owner);
await linkUserToProject(member, project, 'project:editor');
const dataTable = await createDataTable(project, {
name: 'Editable Table',
columns: [{ name: 'data', type: 'string' }],
});
const response = await authMemberAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
expect(response.body.data.dataTableName).toBe('Editable Table');
expect(response.body.data.csvContent).toContain('id,data,createdAt,updatedAt');
});
test('should download CSV from personal project data table', async () => {
const dataTable = await createDataTable(memberProject, {
name: 'Personal CSV Export',
columns: [{ name: 'info', type: 'string' }],
});
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
await dataTableRowsRepository.insertRows(
dataTable.id,
[{ info: 'personal data' }],
columns,
'id',
);
const response = await authMemberAgent
.get(`/projects/${memberProject.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
expect(response.body.data.dataTableName).toBe('Personal CSV Export');
expect(response.body.data.csvContent).toContain('personal data');
});
test('should handle table name with special characters', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Table "With" Special, Chars',
columns: [{ name: 'data', type: 'string' }],
});
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
// Table name should be returned correctly
expect(response.body.data.dataTableName).toBe('Table "With" Special, Chars');
});
test('should handle column names with underscores and numbers', async () => {
const project = await createTeamProject('test project', owner);
const dataTable = await createDataTable(project, {
name: 'Valid Column Names',
columns: [
{ name: 'first_name', type: 'string' },
{ name: 'age_2', type: 'string' },
{ name: 'email_address', type: 'string' },
],
});
const response = await authOwnerAgent
.get(`/projects/${project.id}/data-tables/${dataTable.id}/download-csv`)
.expect(200);
const csvContent = response.body.data.csvContent;
const lines = csvContent.split('\n');
// Column names should be included in header
expect(lines[0]).toContain('first_name');
expect(lines[0]).toContain('age_2');
expect(lines[0]).toContain('email_address');
});
});

View File

@ -261,6 +261,36 @@ export class DataTableController {
}
}
@Get('/:dataTableId/download-csv')
@ProjectScope('dataTable:read')
async downloadDataTableCsv(
req: AuthenticatedRequest<{ projectId: string; dataTableId: string }>,
_res: Response,
) {
try {
const { projectId, dataTableId } = req.params;
// Generate CSV content - this will validate that the table exists
const { csvContent, dataTableName } = await this.dataTableService.generateDataTableCsv(
dataTableId,
projectId,
);
return {
csvContent,
dataTableName,
};
} catch (e: unknown) {
if (e instanceof DataTableNotFoundError) {
throw new NotFoundError(e.message);
} else if (e instanceof Error) {
throw new InternalServerError(e.message, e);
} else {
throw e;
}
}
}
/**
* @returns the IDs of the inserted rows
*/

View File

@ -688,4 +688,109 @@ export class DataTableService {
dataTables: accessibleDataTables,
};
}
async generateDataTableCsv(
dataTableId: string,
projectId: string,
): Promise<{ csvContent: string; dataTableName: string }> {
const dataTable = await this.validateDataTableExists(dataTableId, projectId);
const columns = await this.dataTableColumnRepository.getColumns(dataTableId);
const { data: rows } = await this.dataTableRowsRepository.getManyAndCount(
dataTableId,
{
skip: 0,
},
columns,
);
const csvContent = this.buildCsvContent(rows, columns);
return {
csvContent,
dataTableName: dataTable.name,
};
}
private buildCsvContent(rows: DataTableRowReturn[], columns: DataTableColumn[]): string {
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const userHeaders = sortedColumns.map((col) => col.name);
const headers = ['id', ...userHeaders, 'createdAt', 'updatedAt'];
const csvRows: string[] = [headers.map((h) => this.escapeCsvValue(h)).join(',')];
for (const row of rows) {
const values: string[] = [];
values.push(this.escapeCsvValue(row.id));
for (const column of sortedColumns) {
const value = row[column.name];
values.push(this.escapeCsvValue(this.formatValueForCsv(value, column.type)));
}
values.push(this.escapeCsvValue(this.formatDateForCsv(row.createdAt)));
values.push(this.escapeCsvValue(this.formatDateForCsv(row.updatedAt)));
csvRows.push(values.join(','));
}
return csvRows.join('\n');
}
private formatValueForCsv(value: unknown, columnType: DataTableColumnType): string {
if (value === null || value === undefined) {
return '';
}
if (columnType === 'date') {
if (value instanceof Date || typeof value === 'string') {
return this.formatDateForCsv(value);
}
}
if (columnType === 'boolean') {
return String(value);
}
if (columnType === 'number') {
return String(value);
}
return String(value);
}
private formatDateForCsv(date: Date | string): string {
if (date instanceof Date) {
return date.toISOString();
}
// If it's already a string, try to parse and format
const parsed = new Date(date);
return !isNaN(parsed.getTime()) ? parsed.toISOString() : String(date);
}
private escapeCsvValue(value: unknown): string {
const str = String(value);
// RFC 4180 compliant escaping:
// - If value contains comma, quote, or newline, wrap in quotes
// - Also wrap if value has leading/trailing spaces to prevent trimming
// - Escape quotes by doubling them
const hasLeadingOrTrailingSpace =
str.length > 0 && (str[0] === ' ' || str[str.length - 1] === ' ');
if (
str.includes(',') ||
str.includes('"') ||
str.includes('\n') ||
str.includes('\r') ||
hasLeadingOrTrailingSpace
) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
}

View File

@ -3334,6 +3334,8 @@
"dataTable.import.invalidColumnName": "Only alphabetical and non-leading numbers and underscores allowed",
"dataTable.delete.confirm.message": "Are you sure you want to delete the data table '{name}'? This action cannot be undone.",
"dataTable.delete.error": "Error deleting data table",
"dataTable.download.csv": "Download CSV",
"dataTable.download.error": "Failed to download data table",
"dataTable.rename.error": "Error renaming data table",
"dataTable.getDetails.error": "Error fetching data table details",
"dataTable.notFound": "Data table not found",

View File

@ -40,6 +40,11 @@ const telemetry = useTelemetry();
const actions = computed<Array<UserAction<IUser>>>(() => {
const availableActions = [
{
label: i18n.baseText('dataTable.download.csv'),
value: DATA_TABLE_CARD_ACTIONS.DOWNLOAD_CSV,
disabled: false,
},
{
label: i18n.baseText('generic.delete'),
value: DATA_TABLE_CARD_ACTIONS.DELETE,
@ -67,6 +72,10 @@ const onAction = async (action: string) => {
});
break;
}
case DATA_TABLE_CARD_ACTIONS.DOWNLOAD_CSV: {
await downloadDataTableCsv();
break;
}
case DATA_TABLE_CARD_ACTIONS.DELETE: {
const promptResponse = await message.confirm(
i18n.baseText('dataTable.delete.confirm.message', {
@ -86,6 +95,19 @@ const onAction = async (action: string) => {
}
};
const downloadDataTableCsv = async () => {
try {
await dataTableStore.downloadDataTableCsv(props.dataTable.id, props.dataTable.projectId);
telemetry.track('User downloaded data table CSV', {
data_table_id: props.dataTable.id,
data_table_project_id: props.dataTable.projectId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataTable.download.error'));
}
};
const deleteDataTable = async () => {
try {
const deleted = await dataTableStore.deleteDataTable(

View File

@ -21,6 +21,7 @@ export const DATA_TABLE_CARD_ACTIONS = {
RENAME: 'rename',
DELETE: 'delete',
CLEAR: 'clear',
DOWNLOAD_CSV: 'download-csv',
};
export const ADD_DATA_TABLE_MODAL_KEY = 'addDataTableModal';

View File

@ -223,6 +223,25 @@ export const fetchDataTableGlobalLimitInBytes = async (context: IRestApiContext)
);
};
export const downloadDataTableCsvApi = async (
context: IRestApiContext,
dataTableId: string,
projectId: string,
): Promise<{ csvContent: string; filename: string }> => {
const response = await makeRestApiRequest<{ csvContent: string; dataTableName: string }>(
context,
'GET',
`/projects/${projectId}/data-tables/${dataTableId}/download-csv`,
);
// Use just the data table name as filename
const filename = `${response.dataTableName}.csv`;
return {
csvContent: response.csvContent,
filename,
};
};
export const uploadCsvFileApi = async (
context: IRestApiContext,
file: File,

View File

@ -15,6 +15,7 @@ import {
updateDataTableRowsApi,
deleteDataTableRowsApi,
fetchDataTableGlobalLimitInBytes,
downloadDataTableCsvApi,
uploadCsvFileApi,
} from '@/features/core/dataTable/dataTable.api';
import type {
@ -39,6 +40,8 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
const dataTableSizeLimitState = ref<DataTableSizeStatus>('ok');
const dataTableTableSizes = ref<Record<string, number>>({});
const UTF8_BOM = '\uFEFF';
const projectPermissions = computed(() =>
getResourcePermissions(
projectStore.currentProject?.scopes ?? projectStore.personalProject?.scopes,
@ -273,6 +276,45 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
return result;
};
const createCsvBlob = (csvContent: string): Blob => {
// Add BOM for Excel compatibility with special characters
return new Blob([UTF8_BOM + csvContent], {
type: 'text/csv;charset=utf-8;',
});
};
const triggerBrowserDownload = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
try {
link.click();
} finally {
// Ensure cleanup happens even if click fails
if (document.body.contains(link)) {
document.body.removeChild(link);
}
URL.revokeObjectURL(url);
}
};
const downloadDataTableCsv = async (dataTableId: string, projectId: string) => {
const { csvContent, filename } = await downloadDataTableCsvApi(
rootStore.restApiContext,
dataTableId,
projectId,
);
const csvBlob = createCsvBlob(csvContent);
triggerBrowserDownload(csvBlob, filename);
};
return {
dataTables,
totalCount,
@ -295,6 +337,7 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => {
insertEmptyRow,
updateRow,
deleteRows,
downloadDataTableCsv,
projectPermissions,
};
});