mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
feat(editor): CSV download support for data tables (#22048)
This commit is contained in:
parent
94505bfad4
commit
81a3d395f2
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user