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:
parent
3c450dffd6
commit
6e4c2b7242
|
@ -1 +1 @@
|
||||||
Subproject commit ab324e35d855012bd0f49caa53c6dd765223c6fa
|
Subproject commit de6d44c372a7f48ca0ce8c6c0c19311d4bc21646
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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" }),
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue