Export data make CSV delimiter configurable (#13028)

* Add delimiter option

* Add custom delimiter

* external export delimiter

* Custom headers for row export

* External export rows custom headers

* Support custom JSON export labels

* Handle export table source switch

* update account portal

* Add space as delimiter

* Refactor

* update account portal
This commit is contained in:
melohagan 2024-02-27 09:23:49 +00:00 committed by GitHub
parent 3c450dffd6
commit 6e4c2b7242
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 174 additions and 43 deletions

@ -1 +1 @@
Subproject commit ab324e35d855012bd0f49caa53c6dd765223c6fa Subproject commit de6d44c372a7f48ca0ce8c6c0c19311d4bc21646

View File

@ -1,9 +1,9 @@
<script> <script>
import { Label, Select, Body, Multiselect } from "@budibase/bbui" import { Label, Select, Body } from "@budibase/bbui"
import { findAllMatchingComponents, findComponent } from "helpers/components"
import { selectedScreen } from "stores/builder"
import { onMount } from "svelte" import { onMount } from "svelte"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import ColumnEditor from "../../ColumnEditor/ColumnEditor.svelte"
import { findAllMatchingComponents } from "helpers/components"
import { selectedScreen } from "stores/builder"
export let parameters export let parameters
@ -18,37 +18,65 @@
}, },
] ]
const DELIMITERS = [
{
label: ",",
value: ",",
},
{
label: ";",
value: ";",
},
{
label: ":",
value: ":",
},
{
label: "|",
value: "|",
},
{
label: "~",
value: "~",
},
{
label: "[tab]",
value: "\t",
},
{
label: "[space]",
value: " ",
},
]
$: tables = findAllMatchingComponents($selectedScreen?.props, component => $: tables = findAllMatchingComponents($selectedScreen?.props, component =>
component._component.endsWith("table") component._component.endsWith("table")
).map(table => ({ )
label: table._instanceName,
value: table._id,
}))
$: tableBlocks = findAllMatchingComponents( $: tableBlocks = findAllMatchingComponents(
$selectedScreen?.props, $selectedScreen?.props,
component => component._component.endsWith("tableblock") component => component._component.endsWith("tableblock")
).map(block => ({ )
label: block._instanceName, $: components = tables.concat(tableBlocks)
value: `${block._id}-table`, $: componentOptions = components.map(table => ({
label: table._instanceName,
value: table._component.includes("tableblock")
? `${table._id}-table`
: table._id,
})) }))
$: componentOptions = tables.concat(tableBlocks) $: selectedTableId = parameters.tableComponentId?.includes("-")
$: columnOptions = getColumnOptions(parameters.tableComponentId) ? parameters.tableComponentId.split("-")[0]
: parameters.tableComponentId
const getColumnOptions = tableId => { $: selectedTable = components.find(
// Strip block suffix if block component component => component._id === selectedTableId
if (tableId?.includes("-")) { )
tableId = tableId.split("-")[0]
}
const selectedTable = findComponent($selectedScreen?.props, tableId)
const datasource = getDatasourceForProvider($selectedScreen, selectedTable)
const { schema } = getSchemaForDatasource($selectedScreen, datasource)
return Object.keys(schema || {})
}
onMount(() => { onMount(() => {
if (!parameters.type) { if (!parameters.type) {
parameters.type = "csv" parameters.type = "csv"
} }
if (!parameters.delimiter) {
parameters.delimiter = ","
}
}) })
</script> </script>
@ -67,13 +95,29 @@
options={componentOptions} options={componentOptions}
on:change={() => (parameters.columns = [])} on:change={() => (parameters.columns = [])}
/> />
<span />
<Label small>Export as</Label> <Label small>Export as</Label>
<Select bind:value={parameters.type} options={FORMATS} /> <Select bind:value={parameters.type} options={FORMATS} />
<Select
bind:value={parameters.delimiter}
placeholder={null}
options={DELIMITERS}
disabled={parameters.type !== "csv"}
/>
<Label small>Export columns</Label> <Label small>Export columns</Label>
<Multiselect <ColumnEditor
placeholder="All columns"
bind:value={parameters.columns} bind:value={parameters.columns}
options={columnOptions} allowCellEditing={false}
componentInstance={selectedTable}
on:change={e => {
const columns = e.detail
parameters.customHeaders = columns.reduce((headerMap, column) => {
return {
[column.name]: column.displayName,
...headerMap,
}
}, {})
}}
/> />
</div> </div>
</div> </div>
@ -97,8 +141,8 @@
.params { .params {
display: grid; display: grid;
column-gap: var(--spacing-xs); column-gap: var(--spacing-xs);
row-gap: var(--spacing-s); row-gap: var(--spacing-m);
grid-template-columns: 90px 1fr; grid-template-columns: 90px 1fr 90px;
align-items: center; align-items: center;
} }
</style> </style>

View File

@ -29,6 +29,12 @@
allowLinks: true, allowLinks: true,
}) })
$: {
value = (value || []).filter(
column => (schema || {})[column.name || column] !== undefined
)
}
const getText = value => { const getText = value => {
if (!value?.length) { if (!value?.length) {
return "All columns" return "All columns"

View File

@ -341,7 +341,11 @@ const exportDataHandler = async action => {
tableId: selection.tableId, tableId: selection.tableId,
rows: selection.selectedRows, rows: selection.selectedRows,
format: action.parameters.type, format: action.parameters.type,
columns: action.parameters.columns, columns: action.parameters.columns?.map(
column => column.name || column
),
delimiter: action.parameters.delimiter,
customHeaders: action.parameters.customHeaders,
}) })
download( download(
new Blob([data], { type: "text/plain" }), new Blob([data], { type: "text/plain" }),

View File

@ -89,13 +89,24 @@ export const buildRowEndpoints = API => ({
* @param rows the array of rows to export * @param rows the array of rows to export
* @param format the format to export (csv or json) * @param format the format to export (csv or json)
* @param columns which columns to export (all if undefined) * @param columns which columns to export (all if undefined)
* @param delimiter how values should be separated in a CSV (default is comma)
*/ */
exportRows: async ({ tableId, rows, format, columns, search }) => { exportRows: async ({
tableId,
rows,
format,
columns,
search,
delimiter,
customHeaders,
}) => {
return await API.post({ return await API.post({
url: `/api/${tableId}/rows/exportRows?format=${format}`, url: `/api/${tableId}/rows/exportRows?format=${format}`,
body: { body: {
rows, rows,
columns, columns,
delimiter,
customHeaders,
...search, ...search,
}, },
parseResponse: async response => { parseResponse: async response => {

View File

@ -223,7 +223,8 @@ export const exportRows = async (
const format = ctx.query.format const format = ctx.query.format
const { rows, columns, query, sort, sortOrder } = ctx.request.body const { rows, columns, query, sort, sortOrder, delimiter, customHeaders } =
ctx.request.body
if (typeof format !== "string" || !exporters.isFormat(format)) { if (typeof format !== "string" || !exporters.isFormat(format)) {
ctx.throw( ctx.throw(
400, 400,
@ -241,6 +242,8 @@ export const exportRows = async (
query, query,
sort, sort,
sortOrder, sortOrder,
delimiter,
customHeaders,
}) })
ctx.attachment(fileName) ctx.attachment(fileName)
ctx.body = apiFileReturn(content) ctx.body = apiFileReturn(content)

View File

@ -1,7 +1,19 @@
import { Row, TableSchema } from "@budibase/types" import { Row, TableSchema } from "@budibase/types"
export function csv(headers: string[], rows: Row[]) { function getHeaders(
let csv = headers.map(key => `"${key}"`).join(",") headers: string[],
customHeaders: { [key: string]: string }
) {
return headers.map(header => `"${customHeaders[header] || header}"`)
}
export function csv(
headers: string[],
rows: Row[],
delimiter: string = ",",
customHeaders: { [key: string]: string } = {}
) {
let csv = getHeaders(headers, customHeaders).join(delimiter)
for (let row of rows) { for (let row of rows) {
csv = `${csv}\n${headers csv = `${csv}\n${headers
@ -15,7 +27,7 @@ export function csv(headers: string[], rows: Row[]) {
: "" : ""
return val.trim() return val.trim()
}) })
.join(",")}` .join(delimiter)}`
} }
return csv return csv
} }

View File

@ -36,11 +36,13 @@ export async function search(options: SearchParams): Promise<{
export interface ExportRowsParams { export interface ExportRowsParams {
tableId: string tableId: string
format: Format format: Format
delimiter?: string
rowIds?: string[] rowIds?: string[]
columns?: string[] columns?: string[]
query?: SearchFilters query?: SearchFilters
sort?: string sort?: string
sortOrder?: SortOrder sortOrder?: SortOrder
customHeaders?: { [key: string]: string }
} }
export interface ExportRowsResult { export interface ExportRowsResult {

View File

@ -101,7 +101,17 @@ export async function search(options: SearchParams) {
export async function exportRows( export async function exportRows(
options: ExportRowsParams options: ExportRowsParams
): Promise<ExportRowsResult> { ): Promise<ExportRowsResult> {
const { tableId, format, columns, rowIds, query, sort, sortOrder } = options const {
tableId,
format,
columns,
rowIds,
query,
sort,
sortOrder,
delimiter,
customHeaders,
} = options
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
let requestQuery: SearchFilters = {} let requestQuery: SearchFilters = {}
@ -153,12 +163,17 @@ export async function exportRows(
rows = result.rows rows = result.rows
} }
let exportRows = cleanExportRows(rows, schema, format, columns) let exportRows = cleanExportRows(rows, schema, format, columns, customHeaders)
let content: string let content: string
switch (format) { switch (format) {
case exporters.Format.CSV: case exporters.Format.CSV:
content = exporters.csv(headers ?? Object.keys(schema), exportRows) content = exporters.csv(
headers ?? Object.keys(schema),
exportRows,
delimiter,
customHeaders
)
break break
case exporters.Format.JSON: case exporters.Format.JSON:
content = exporters.json(exportRows) content = exporters.json(exportRows)

View File

@ -84,7 +84,17 @@ export async function search(options: SearchParams) {
export async function exportRows( export async function exportRows(
options: ExportRowsParams options: ExportRowsParams
): Promise<ExportRowsResult> { ): Promise<ExportRowsResult> {
const { tableId, format, rowIds, columns, query, sort, sortOrder } = options const {
tableId,
format,
rowIds,
columns,
query,
sort,
sortOrder,
delimiter,
customHeaders,
} = options
const db = context.getAppDB() const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
@ -124,11 +134,16 @@ export async function exportRows(
rows = result rows = result
} }
let exportRows = cleanExportRows(rows, schema, format, columns) let exportRows = cleanExportRows(rows, schema, format, columns, customHeaders)
if (format === Format.CSV) { if (format === Format.CSV) {
return { return {
fileName: "export.csv", fileName: "export.csv",
content: csv(headers ?? Object.keys(rows[0]), exportRows), content: csv(
headers ?? Object.keys(rows[0]),
exportRows,
delimiter,
customHeaders
),
} }
} else if (format === Format.JSON) { } else if (format === Format.JSON) {
return { return {

View File

@ -16,7 +16,8 @@ export function cleanExportRows(
rows: any[], rows: any[],
schema: TableSchema, schema: TableSchema,
format: string, format: string,
columns?: string[] columns?: string[],
customHeaders: { [key: string]: string } = {}
) { ) {
let cleanRows = [...rows] let cleanRows = [...rows]
@ -44,11 +45,27 @@ export function cleanExportRows(
} }
} }
} }
} else if (format === Format.JSON) {
// Replace row keys with custom headers
for (let row of cleanRows) {
renameKeys(customHeaders, row)
}
} }
return cleanRows return cleanRows
} }
function renameKeys(keysMap: { [key: string]: any }, row: any) {
for (const key in keysMap) {
Object.defineProperty(
row,
keysMap[key],
Object.getOwnPropertyDescriptor(row, key) || {}
)
delete row[key]
}
}
function isForeignKey(key: string, table: Table) { function isForeignKey(key: string, table: Table) {
const relationships = Object.values(table.schema).filter(isRelationshipColumn) const relationships = Object.values(table.schema).filter(isRelationshipColumn)
return relationships.some( return relationships.some(

View File

@ -37,6 +37,8 @@ export interface ExportRowsRequest {
query?: SearchFilters query?: SearchFilters
sort?: string sort?: string
sortOrder?: SortOrder sortOrder?: SortOrder
delimiter?: string
customHeaders?: { [key: string]: string }
} }
export type ExportRowsResponse = ReadStream export type ExportRowsResponse = ReadStream