diff --git a/.cspell-words.txt b/.cspell-words.txt index 40b2e33317..85894e8b63 100644 --- a/.cspell-words.txt +++ b/.cspell-words.txt @@ -1,5 +1,6 @@ addrbar akottr +apos bootcss borderless bowser diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2070c541ab..08d8aac442 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ restrictions: * Please **do not** open issues or pull requests regarding the code in [`bootstrap-table-examples`](https://github.com/wenzhixin/bootstrap-table-examples) and [`extensions plugin dependence`](https://github.com/wenzhixin/bootstrap-table/tree/develop/src/extensions) (open them in their respective repositories), the dependence list: * Table Editable: [x-editable](https://github.com/vitalets/x-editable) - * Table Export: [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin) + * Table Export: built-in exporter (no external plugin) * Table Reorder-Columns: [jquery-ui](https://code.jquery.com/ui/) and [dragTable](https://github.com/akottr/dragtable/) * Table Reorder-Rows: [tablednd](https://github.com/isocra/TableDnD) * Table Resizable: [jquery-resizable-columns](https://github.com/dobtco/jquery-resizable-columns) diff --git a/cypress/extensions/export/options.js b/cypress/extensions/export/options.js index b10f7de9c4..73153794fd 100644 --- a/cypress/extensions/export/options.js +++ b/cypress/extensions/export/options.js @@ -174,41 +174,76 @@ module.exports = (theme = '') => { cy.visit(`${baseUrl}export-method.html`) .get('.fixed-table-body') .should('exist') - .window().then(win => { - const $table = win.$('#table') - - // Verify the method exists - expect(typeof $table.bootstrapTable).to.equal('function') - - let tableExportCalled = false - const originalTableExport = win.$.fn.tableExport - - try { - // eslint-disable-next-line no-unused-vars - win.$.fn.tableExport = function (_options) { - tableExportCalled = true - return this - } - - let exportStarted = false - let exportSaved = false - - $table.on('export-started.bs.table', () => { - exportStarted = true - }) - $table.on('export-saved.bs.table', () => { - exportSaved = true - }) - - $table.bootstrapTable('exportTable', { type: 'csv' }) - - expect(exportStarted, 'export-started event should be triggered').to.be.true - expect(exportSaved, 'export-saved event should be triggered').to.be.true - expect(tableExportCalled, 'tableExport plugin should be called').to.be.true - } finally { - win.$.fn.tableExport = originalTableExport - } + + cy.window().then(win => { + const $table = win.$('#table') + let exportStarted = false + let exportSaved = false + let downloadName = null + let capturedBlob = null + + const createObjectURL = cy.stub(win.URL, 'createObjectURL').callsFake(blob => { + capturedBlob = blob + return 'blob:mock' + }) + + cy.stub(win.URL, 'revokeObjectURL').callsFake(() => {}) + cy.stub(win.HTMLAnchorElement.prototype, 'click').callsFake(function () { + downloadName = this.download + }) + + $table.on('export-started.bs.table', () => { + exportStarted = true }) + $table.on('export-saved.bs.table', () => { + exportSaved = true + }) + + $table.bootstrapTable('exportTable', { type: 'csv' }) + + expect(exportStarted, 'export-started event should be triggered').to.be.true + expect(exportSaved, 'export-saved event should be triggered').to.be.true + expect(createObjectURL, 'a blob should be created').to.have.been.called + expect(downloadName, 'the file should be named table.csv').to.equal('table.csv') + + return capturedBlob.text().then(text => { + expect(text).to.contain('ID,Name,Price') + expect(text).to.contain('1,Item 1,$1') + expect(text).to.contain('2,Item 2,$2') + }) + }) + }) + + // Methods: exportTable with an unsupported type + it('Test exportTable() method does not export unsupported types', () => { + cy.visit(`${baseUrl}export-method.html`) + .get('.fixed-table-body') + .should('exist') + + cy.window().then(win => { + const $table = win.$('#table') + let exportStarted = false + let exportSaved = false + + const createObjectURL = cy.stub(win.URL, 'createObjectURL').returns('blob:mock') + + cy.stub(win.URL, 'revokeObjectURL').callsFake(() => {}) + cy.stub(win.HTMLAnchorElement.prototype, 'click').callsFake(() => {}) + + $table.on('export-started.bs.table', () => { + exportStarted = true + }) + $table.on('export-saved.bs.table', () => { + exportSaved = true + }) + + const result = $table.bootstrapTable('exportTable', { type: 'pdf' }) + + expect(result).to.equal(false) + expect(exportStarted, 'export-started event should not be triggered').to.be.false + expect(exportSaved, 'export-saved event should not be triggered').to.be.false + expect(createObjectURL).not.to.have.been.called + }) }) // Options: exportOptions.fileName as function diff --git a/site/src/pages/docs/extensions/export.mdx b/site/src/pages/docs/extensions/export.mdx index fa0ca21fe1..d4fdbd0e0e 100644 --- a/site/src/pages/docs/extensions/export.mdx +++ b/site/src/pages/docs/extensions/export.mdx @@ -6,9 +6,16 @@ group: extensions toc: true --- -Use Plugin: [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin) +This extension now uses a built-in exporter (no external plugin required). -This is an important link to check out as some file types may require extra steps. +> **Note** +> +> Since the deprecated [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin) has been removed, +> the following types are **no longer supported**: `png`, `pdf`, `xlsx`, `doc` and `powerpoint`. +> Export / table values are read from the table data model instead of the rendered DOM, so the plugin specific +> options such as `ignoreColumn`, `csvSeparator`, `csvEnclosure`, `ignoreRow` and `displayTableName` are ignored. +> If you need one of the removed formats, please use a dedicated library (for example SheetJS or jsPDF) together with +> the `getData` method. ## Usage @@ -44,6 +51,14 @@ This is an important link to check out as some file types may require extra step Export data type, support: `'basic'`, `'all'`, `'selected'`. + * `'basic'`: exports the rows of the current page only. + * `'all'`: exports all rows of all pages. + * `'selected'`: exports the selected rows only, nothing is exported if no row is selected. + + The selected columns are defined by the `forceExport`/`forceHide` and `visible` column options, the checkbox/radio + state column is always skipped. Cell values are the formatter results with their HTML tags stripped, + mirroring what is displayed in the table. + - **Default:** `basic` ### exportFooter @@ -54,7 +69,8 @@ This is an important link to check out as some file types may require extra step - **Detail:** - Set `true` to export the table footer. + Set `true` to export the table footer. The footer is appended as the last row of the exported file, + using the `footerFormatter` of every exported column. - **Default:** `false` @@ -66,7 +82,17 @@ This is an important link to check out as some file types may require extra step - **Detail:** - Export [options](https://github.com/hhurz/tableExport.jquery.plugin#options) of `tableExport.jquery.plugin` + Options for the built-in exporter: + + - `fileName`: `String` or `Function`, the filename without extension (default `table`). + - `csvDelimiter`: `String`, the delimiter used by the `csv` and `excel` types (default `,`). + - `tableName`: `String`, the table name used by the `sql` and `xml` types (default `table`). + + Options passed to the `exportTable` method are merged into these options, for example: + + ```js + $('#table').bootstrapTable('exportTable', { type: 'csv', fileName: 'my-report' }) + ``` `exportOptions.fileName` can be a string or a function, for example: @@ -86,7 +112,20 @@ This is an important link to check out as some file types may require extra step - **Detail:** - Export types, support types: `['json', 'xml', 'png', 'csv', 'txt', 'sql', 'doc', 'excel', 'xlsx', 'pdf']`. + Built-in export types: `['json', 'xml', 'csv', 'txt', 'sql', 'excel']`. + Note: `png`, `xlsx`, `pdf`, `doc`, `powerpoint` are not supported by the built-in exporter. + + * `csv`/`txt`: values are joined with the delimiter, CSV files start with a UTF-8 BOM and use `CRLF` line endings + so that spreadsheet applications keep the encoding. + * `json`: each row is an object keyed by the column `field` (not by the column title). + * `xml`: the root element comes from `exportOptions.tableName`, the row elements are `` children, + column tags are derived from the column `field` and sanitized to valid XML names. + * `sql`: generates a single `INSERT INTO` statement, no file is created when there is no row to export. + * `excel`: **generates CSV content with a `.xls` extension**, it is not a real Excel workbook. + Excel shows a "format differs from the file extension" warning when opening such a file. + + Unsupported types are skipped: `exportTable` returns `false`, logs a warning and neither exports a file + nor triggers the `export-started`/`export-saved` events. - **Default:** `['json', 'xml', 'csv', 'txt', 'sql', 'excel']` diff --git a/site/src/pages/zh-cn/docs/extensions/export.mdx b/site/src/pages/zh-cn/docs/extensions/export.mdx index 99c8cfa0e1..e0fffdd426 100644 --- a/site/src/pages/zh-cn/docs/extensions/export.mdx +++ b/site/src/pages/zh-cn/docs/extensions/export.mdx @@ -6,13 +6,17 @@ group: extensions toc: true --- -数据导出扩展可以将表格数据导出为多种格式,包括 Excel、PDF、CSV 等。 +数据导出扩展可以将表格数据导出为多种格式,包括 JSON、XML、CSV、TXT、SQL 和 Excel。 -## 依赖插件 +本扩展已内置导出实现,不再依赖任何外部插件。 -本扩展依赖 [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin)。 - -> **注意**:某些导出格式可能需要额外的配置或依赖库,请参考上述文档进行配置。 +> **注意** +> +> 已废弃的 [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin) 已被移除, +> 因此 `png`、`pdf`、`xlsx`、`doc`、`powerpoint` 这些格式**不再支持**。 +> 导出内容取自表格的数据模型而非渲染后的 DOM,所以 `ignoreColumn`、`csvSeparator`、`csvEnclosure`、 +> `ignoreRow`、`displayTableName` 等旧插件选项不再生效。 +> 如果确实需要上述格式,请使用 SheetJS、jsPDF 等第三方库配合 `getData` 方法自行实现。 ## 用法 @@ -48,8 +52,11 @@ toc: true 设置导出数据的范围: * `'basic'`:仅导出当前页数据 - * `'all'`:导出所有数据 - * `'selected'`:仅导出选中的行 + * `'all'`:导出所有页的全部数据 + * `'selected'`:仅导出选中的行,未选中任何行时不导出 + + 导出的列由 `visible`、`forceExport`、`forceHide` 列选项决定,复选框/单选框状态列始终会被跳过。 + 单元格取值为 formatter 的处理结果并去除其中的 HTML 标签,即与表格中显示的内容一致。 - **默认值:** `basic` @@ -61,7 +68,8 @@ toc: true - **详情:** - 是否同时导出表格页脚。设置为 `true` 时,表格的页脚行也会包含在导出的文件中。 + 是否同时导出表格页脚。设置为 `true` 时,页脚会作为最后一行追加到导出的文件中, + 每个导出列的值由该列的 `footerFormatter` 决定。 - **默认值:** `false` @@ -73,7 +81,17 @@ toc: true - **详情:** - 传递给 `tableExport.jquery.plugin` 的[导出配置选项](https://github.com/hhurz/tableExport.jquery.plugin#options)。 + 内置导出器的配置选项: + + - `fileName`:`String` 或 `Function`,导出文件名(不含扩展名,默认为 `table`)。 + - `csvDelimiter`:`String`,`csv` 与 `excel` 类型使用的分隔符(默认为 `,`)。 + - `tableName`:`String`,`sql` 与 `xml` 类型使用的表名(默认为 `table`)。 + + 调用 `exportTable` 方法时传入的参数会合并到这些选项中,例如: + + ```js + $('#table').bootstrapTable('exportTable', { type: 'csv', fileName: 'my-report' }) + ``` 其中 `exportOptions.fileName` 可以是字符串或函数,用于设置导出文件的名称: @@ -93,8 +111,21 @@ toc: true - **详情:** - 设置可用的导出格式列表。支持的格式包括: - `json`、`xml`、`png`、`csv`、`txt`、`sql`、`doc`、`excel`、`xlsx`、`pdf` 等。 + 设置可用的导出格式列表。内置支持的格式包括: + `json`、`xml`、`csv`、`txt`、`sql`、`excel`。 + 注意:`png`、`xlsx`、`pdf`、`doc`、`powerpoint` 不再支持。 + + * `csv`/`txt`:按分隔符拼接各行,CSV 文件会写入 UTF-8 BOM 并使用 `CRLF` 换行, + 以保证表格软件不会错误识别编码。 + * `json`:每行是一个以列 `field` 为键的对象(而非以列标题为键)。 + * `xml`:根元素来自 `exportOptions.tableName`,每行是一个 `` 节点, + 子节点名称由列 `field` 生成并会被处理为合法的 XML 名称。 + * `sql`:生成一条 `INSERT INTO` 语句,若没有可导出的行则不生成文件。 + * `excel`:**生成的是 CSV 内容,扩展名为 `.xls`**,并不是真正的 Excel 文件, + 打开时 Excel 会提示"文件格式与扩展名不一致"。 + + 不支持的格式会被跳过:`exportTable` 返回 `false` 并在控制台输出警告, + 既不会导出文件,也不会触发 `export-started`/`export-saved` 事件。 - **默认值:** `['json', 'xml', 'csv', 'txt', 'sql', 'excel']` diff --git a/src/extensions/export/bootstrap-table-export.js b/src/extensions/export/bootstrap-table-export.js index ab09b0fbe9..05d0623b05 100644 --- a/src/extensions/export/bootstrap-table-export.js +++ b/src/extensions/export/bootstrap-table-export.js @@ -1,28 +1,221 @@ /** * @author zhixin wen - * extensions: https://github.com/hhurz/tableExport.jquery.plugin + * Built-in export implementation (replacing deprecated tableExport.jquery.plugin) */ +import DOMHelper from '../../helpers/dom.js' + const Utils = $.fn.bootstrapTable.utils +// Supported export types, the single source of truth for defaults and validation +const EXPORT_TYPES = ['json', 'xml', 'csv', 'txt', 'sql', 'excel'] + const TYPE_NAME = { json: 'JSON', xml: 'XML', - png: 'PNG', csv: 'CSV', txt: 'TXT', sql: 'SQL', - doc: 'MS-Word', - excel: 'MS-Excel', - xlsx: 'MS-Excel (OpenXML)', - powerpoint: 'MS-Powerpoint', - pdf: 'PDF' + excel: 'MS-Excel' +} + +const stringifyValue = value => + value === null || typeof value === 'undefined' ? '' : String(value) + +const csvEscape = (value, delimiter) => { + const d = delimiter || ',' + const str = stringifyValue(value) + + return str.includes(d) || str.includes('"') || str.includes('\n') || str.includes('\r') ? + `"${str.replace(/"/g, '""')}"` : str +} + +// Strip HTML tags and decode entities, mimicking jQuery's `.text()` semantics +const toText = value => { + if (value === null || typeof value === 'undefined') { + return '' + } + + return DOMHelper.text(DOMHelper.html(DOMHelper.create('
'), stringifyValue(value))) +} + +const escapeXmlText = value => stringifyValue(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + +const sanitizeXmlTag = (name, fallback) => { + const tag = stringifyValue(name) + .trim() + .replace(/\s+/g, '_') + .replace(/[^A-Za-z0-9_.-]/g, '') + + return /^[A-Za-z_]/.test(tag) ? tag : fallback +} + +const escapeSqlIdentifier = name => stringifyValue(name).replace(/`/g, '``') + +const sqlValue = value => { + if (value === null || typeof value === 'undefined') { + return 'NULL' + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + return `'${toText(value).replace(/'/g, '\'\'')}'` +} + +const buildSqlColumns = columns => columns.map((column, i) => + `\`${escapeSqlIdentifier(column.field || `col${i + 1}`)}\``) + +const buildXmlTags = columns => { + const used = new Set() + + return columns.map((column, i) => { + let tag = sanitizeXmlTag(column.field, `col${i + 1}`) + + if (used.has(tag)) { + tag = `${tag}_${i + 1}` + } + + used.add(tag) + return tag + }) +} + +const buildFile = (type, matrix, exportOptions) => { + const { columns, rows, values } = matrix + const options = exportOptions || {} + const delimiter = options.csvDelimiter || ',' + const tableName = options.tableName || 'table' + const headerRow = columns.map(column => column.title) + + switch (type) { + case 'csv': { + const lines = [] + + if (headerRow.length) { + lines.push(headerRow.map(title => csvEscape(title, delimiter)).join(delimiter)) + } + + rows.forEach(row => lines.push(row.map(value => csvEscape(value, delimiter)).join(delimiter))) + + return { + mime: 'text/csv;charset=utf-8', + ext: 'csv', + // The BOM keeps Excel from mis-detecting the UTF-8 encoding + content: `\uFEFF${lines.join('\r\n')}` + } + } + case 'txt': { + const lines = [] + + if (headerRow.length) { + lines.push(headerRow.join('\t')) + } + + rows.forEach(row => lines.push(row.join('\t'))) + + return { + mime: 'text/plain;charset=utf-8', + ext: 'txt', + content: lines.join('\r\n') + } + } + case 'json': { + const objects = values.map(rowValues => { + const object = {} + + columns.forEach((column, i) => { + object[column.field] = rowValues[i] + }) + return object + }) + + return { + mime: 'application/json;charset=utf-8', + ext: 'json', + content: JSON.stringify(objects, null, 2) + } + } + case 'xml': { + const root = sanitizeXmlTag(tableName, 'table') + const tags = buildXmlTags(columns) + const lines = ['', `<${root}>`] + + rows.forEach(row => { + lines.push(' ') + + columns.forEach((column, i) => { + lines.push(` <${tags[i]}>${escapeXmlText(row[i])}`) + }) + + lines.push(' ') + }) + + lines.push(``) + + return { + mime: 'application/xml;charset=utf-8', + ext: 'xml', + content: lines.join('\n') + } + } + case 'sql': { + if (!rows.length) { + return { + mime: 'application/sql;charset=utf-8', + ext: 'sql', + content: '' + } + } + + const rowsSql = values.map( + rowValues => `(${rowValues.map(value => sqlValue(value)).join(', ')})` + ) + + return { + mime: 'application/sql;charset=utf-8', + ext: 'sql', + content: `INSERT INTO \`${escapeSqlIdentifier(tableName)}\` (${buildSqlColumns(columns).join(', ')}) VALUES\n${rowsSql.join(',\n')};` + } + } + case 'excel': { + const csv = buildFile('csv', matrix, exportOptions) + + // Kept for backwards compatibility: the content is CSV with an .xls extension + return { + mime: 'application/vnd.ms-excel', + ext: 'xls', + content: csv.content + } + } + default: + return null + } +} + +const saveAs = (content, mime, filename) => { + const blob = new Blob([content], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) } Object.assign($.fn.bootstrapTable.defaults, { showExport: false, exportDataType: 'basic', // basic, all, selected - exportTypes: ['json', 'xml', 'csv', 'txt', 'sql', 'excel'], + exportTypes: [...EXPORT_TYPES], exportOptions: {}, exportFooter: false }) @@ -173,162 +366,133 @@ $.BootstrapTable = class extends $.BootstrapTable { } } - exportTable (options) { - const o = this.options - const stateField = this.header.stateField - const isCardView = o.cardView - - const doExport = callback => { - if (stateField) { - this.hideColumn(stateField) - } - if (isCardView) { - this.toggleView() - } + getExportColumns () { + return this.columns.filter(column => column.field && + column.field !== this.header.stateField && + (column.visible || column.forceExport) && + !column.forceHide) + } - this.columns.forEach(row => { - if (row.forceHide) { - this.hideColumn(row.field) - } - }) + getExportValue (row, column) { + const o = this.options + const value_ = Utils.getItemField(row, column.field, o.escape, column.escape) - const data = this.getData() + return Utils.calculateObjectValue(column, + this.header.formatters[column.fieldIndex], + [value_, row, row.index, column.field], + value_) + } - if (o.detailView && o.detailViewIcon) { - const detailViewIndex = o.detailViewAlign === 'left' ? 0 : this.getVisibleFields().length + Utils.getDetailViewIndexOffset(this.options) + collectExportData (data) { + const o = this.options + const exportColumns = this.getExportColumns() + const columns = exportColumns.map(column => { + const title = column.title || column.field - o.exportOptions.ignoreColumn = [detailViewIndex].concat(o.exportOptions.ignoreColumn || []) + return { + field: column.field, + title: toText(o.escape && o.escapeTitle ? Utils.escapeHTML(title) : title) } + }) + const rows = data.map(row => exportColumns.map(column => this.getExportValue(row, column))) + // `rows` holds the raw values, `textRows` the html stripped text used by most types + const textRows = rows.map(row => row.map(value => toText(value))) - if (o.exportFooter && o.height) { - const $footerRow = this.$tableFooter.find('tr').first() - const footerData = {} - const footerHtml = [] - - $footerRow.children().forEach((footerCell, index) => { - const footerCellHtml = $(footerCell).children('.th-inner').first().html() + if (o.exportFooter) { + const footerData = this.getFooterData() - footerData[this.columns[index].field] = footerCellHtml === ' ' ? null : footerCellHtml + if (footerData.length) { + const footerRow = exportColumns.map(column => { + const base = footerData[0][column.field] || '' - // grab footer cell text into cell index-based array - footerHtml.push(footerCellHtml) + return Utils.calculateObjectValue(column, + column.footerFormatter, + [this.getData(), base], + base) }) - this.$body.append(this.$body.children().last()[0].outerHTML) - const $lastTableRow = this.$body.children().last() - - $lastTableRow.children().forEach((lastTableRowCell, index) => { - $(lastTableRowCell).html(footerHtml[index]) - }) + rows.push(footerRow) + textRows.push(footerRow.map(value => toText(value))) } + } - const hiddenColumns = this.getHiddenColumns() + return { columns, rows: textRows, values: rows } + } - hiddenColumns.forEach(row => { - if (row.forceExport) { - this.showColumn(row.field) - } - }) + exportTable (options) { + const o = this.options + let selectedData = null - if (typeof o.exportOptions.fileName === 'function') { - options.fileName = o.exportOptions.fileName() - } + // Early return for selected data type when no rows are selected + if (o.exportDataType === 'selected') { + selectedData = this.getSelections() - this.$el.tableExport(Utils.extend({ - onAfterSaveToFile: () => { - if (o.exportFooter) { - this.load(data) - } - - if (stateField) { - this.showColumn(stateField) - } - if (isCardView) { - this.toggleView() - } - - hiddenColumns.forEach(row => { - if (row.forceExport) { - this.hideColumn(row.field) - } - }) + if (!selectedData.length) { + return false + } + } - this.columns.forEach(row => { - if (row.forceHide) { - this.showColumn(row.field) - } - }) + const exportOptions = Utils.extend({}, o.exportOptions, options) + const type = String(exportOptions.type || o.exportTypes[0] || 'csv').toLowerCase() - if (callback) callback() - } - }, o.exportOptions, options)) + if (!EXPORT_TYPES.includes(type)) { + console.warn(`bootstrap-table: unsupported export type "${type}", expected one of ${EXPORT_TYPES.join(', ')}`) + return false } - // Early return for selected data type when no rows are selected - let selectedData = null + const fileName = exportOptions.fileName + const name = typeof fileName === 'function' ? fileName() : fileName || 'table' - if (o.exportDataType === 'selected') { - selectedData = this.getSelections() + const save = data => { + const built = buildFile(type, this.collectExportData(data), exportOptions) - if (!selectedData.length) { + if (!built) { return } + + saveAs(built.content, built.mime, `${name}.${built.ext}`) } // Trigger export-started after early-return checks and before any export operation this.trigger('export-started') - if (o.exportDataType === 'all' && o.pagination) { - const eventName = o.sidePagination === 'server' ? - 'post-body.bs.table' : 'page-change.bs.table' - const virtualScroll = this.options.virtualScroll + if (o.exportDataType === 'all') { + if (o.pagination && o.sidePagination === 'server') { + // Server side data can only be fetched completely by disabling pagination + const virtualScroll = o.virtualScroll - this.$el.one(eventName, () => { - setTimeout(() => { - const data = this.getData() + o.virtualScroll = false + this.$el.one('post-body.bs.table', () => { + setTimeout(() => { + const data = this.getData() - doExport(() => { - this.options.virtualScroll = virtualScroll + save(data) + o.virtualScroll = virtualScroll this.togglePagination() - }) - this.trigger('export-saved', data) - }, 0) - }) - this.options.virtualScroll = false - this.togglePagination() - } else if (o.exportDataType === 'selected') { - let data = this.getData({ - includeHiddenRows: true, - unfiltered: true - }) - const pagination = o.pagination - - if (o.sidePagination === 'server') { - data = { - total: o.totalRows, - [this.options.dataField]: data - } - selectedData = { - total: selectedData.length, - [this.options.dataField]: selectedData - } - } - - this.load(selectedData) - if (pagination) { + this.trigger('export-saved', data) + }, 0) + }) this.togglePagination() + return true } - doExport(() => { - if (pagination) { - this.togglePagination() - } - this.load(data) - }) + + const data = this.getData() + + save(data) + this.trigger('export-saved', data) + return true + } + + if (o.exportDataType === 'selected') { + save(selectedData) this.trigger('export-saved', selectedData) - } else { - doExport() - this.trigger('export-saved', this.getData(true)) + return true } + + // Basic type exports the current page only, as the rendered rows did before + save(this.getData({ useCurrentPage: true })) + this.trigger('export-saved', this.getData(true)) + return true } updateSelected () { diff --git a/tests/extensions/export.test.js b/tests/extensions/export.test.js new file mode 100644 index 0000000000..807e6ebe06 --- /dev/null +++ b/tests/extensions/export.test.js @@ -0,0 +1,649 @@ +/** + * Tests for the built-in export extension. + * + * The exporter is data driven: values are read from the table data model + * instead of the rendered DOM, so the tests assert the produced file content + * through a mocked download. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +if (typeof URL.createObjectURL !== 'function') { + URL.createObjectURL = () => 'blob:mock' +} + +if (typeof URL.revokeObjectURL !== 'function') { + URL.revokeObjectURL = () => {} +} + +let capturedBlob +let downloadName +let saveCount + +async function loadExport () { + if (!globalThis.$) { + const { default: jq } = await import('jquery') + + globalThis.$ = jq + globalThis.jQuery = jq + } + await import('../../src/bootstrap-table.js') + await import('../../src/extensions/export/bootstrap-table-export.js') + + const $ = globalThis.jQuery || globalThis.$ + + expect($?.BootstrapTable).toBeTruthy() + + return { $ } +} + +async function createTable (options = {}) { + await loadExport() + + document.body.innerHTML = '
' + + const $ = globalThis.jQuery + const $table = $('#export-test').bootstrapTable({ + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'price', title: 'Price' } + ], + data: [ + { id: 1, name: 'Item 1', price: '1.00' }, + { id: 2, name: 'Item 2', price: '2.00' } + ], + ...options + }) + + return $table +} + +function destroy ($table) { + if ($table.data('bootstrap.table')) { + $table.bootstrapTable('destroy') + } +} + +async function exportAs ($table, type, options = {}) { + $table.data('bootstrap.table').exportTable({ type, ...options }) + + expect(capturedBlob, 'a file should have been created').toBeTruthy() + + return capturedBlob.text() +} + +describe('export extension', () => { + beforeEach(() => { + capturedBlob = undefined + downloadName = undefined + saveCount = 0 + + vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => { + capturedBlob = blob + saveCount += 1 + return 'blob:mock' + }) + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + downloadName = this.download + }) + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + describe('csv', () => { + it('exports the header and rows with a BOM and CRLF line endings', async () => { + const $table = await createTable() + + try { + const text = await exportAs($table, 'csv') + + expect(downloadName).toBe('table.csv') + expect(text.charCodeAt(0)).toBe(0xFEFF) + expect(text).toContain('ID,Name,Price\r\n') + expect(text).toContain('1,Item 1,1.00\r\n') + expect(text).toContain('2,Item 2,2.00') + } finally { + destroy($table) + } + }) + + it('quotes values containing the delimiter, quotes or newlines', async () => { + const $table = await createTable({ + data: [{ id: 1, name: 'Item, 1', price: 'say "hi"' }, { id: 2, name: 'line\nbreak', price: 'ok' }] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('1,"Item, 1","say ""hi"""') + expect(text).toContain('2,"line\nbreak",ok') + } finally { + destroy($table) + } + }) + + it('supports a custom csv delimiter', async () => { + const $table = await createTable({ + data: [{ id: 1, name: 'Item, 1', price: '1.00' }], + exportOptions: { csvDelimiter: ';' } + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID;Name;Price') + expect(text).toContain('1;Item, 1;1.00') + } finally { + destroy($table) + } + }) + }) + + it('exports txt files with tab separated values', async () => { + const $table = await createTable() + + try { + const text = await exportAs($table, 'txt') + + expect(downloadName).toBe('table.txt') + expect(text).toContain('ID\tName\tPrice\r\n') + expect(text).toContain('1\tItem 1\t1.00') + } finally { + destroy($table) + } + }) + + it('exports json objects keyed by column field', async () => { + const $table = await createTable() + + try { + const text = await exportAs($table, 'json') + + expect(downloadName).toBe('table.json') + expect(text.startsWith('[')).toBe(true) + expect(JSON.parse(text)).toEqual([ + { id: 1, name: 'Item 1', price: '1.00' }, + { id: 2, name: 'Item 2', price: '2.00' } + ]) + expect(text).toContain('\n {\n "id": 1') + } finally { + destroy($table) + } + }) + + describe('xml', () => { + it('sanitizes the root element and column tags into valid xml names', async () => { + const $table = await createTable({ + columns: [ + { field: 'First Name', title: 'First Name' }, + { field: '1st', title: 'First' } + ], + data: [{ 'First Name': 'Tom', '1st': 'x' }], + exportOptions: { tableName: 'My Table' } + }) + + try { + const text = await exportAs($table, 'xml') + + expect(text).toContain('') + expect(text).toContain('') + expect(text).toContain('') + expect(text).toContain('Tom') + expect(text).toContain('x') + expect(text).not.toContain('') + } finally { + destroy($table) + } + }) + + it('deduplicates colliding column tags', async () => { + const $table = await createTable({ + columns: [ + { field: 'name', title: 'Name' }, + { field: 'name?', title: 'Name again' } + ], + data: [{ name: 'a', 'name?': 'b' }] + }) + + try { + const text = await exportAs($table, 'xml') + + expect(text).toContain('a') + expect(text).toContain('b') + } finally { + destroy($table) + } + }) + + it('escapes xml special characters in values', async () => { + const $table = await createTable({ + data: [{ id: 1, name: 'Tom & Jerry', price: '\'q"' }] + }) + + try { + const text = await exportAs($table, 'xml') + + expect(text).toContain('Tom & Jerry') + expect(text).toContain(''q"') + } finally { + destroy($table) + } + }) + }) + + describe('sql', () => { + it('escapes backticks in the table name and quotes by type', async () => { + const $table = await createTable({ + data: [ + { id: 1, name: 'Item 1', price: 1.5 }, + { id: 2, name: null, price: 2 } + ], + exportOptions: { tableName: 'my`table' } + }) + + try { + const text = await exportAs($table, 'sql') + + expect(text).toContain('INSERT INTO `my``table`') + expect(text).toContain('(`id`, `name`, `price`)') + expect(text).toContain('(1, \'Item 1\', 1.5)') + expect(text).toContain('(2, NULL, 2)') + } finally { + destroy($table) + } + }) + + it('escapes single quotes in values', async () => { + const $table = await createTable({ + data: [{ id: 1, name: 'O\'Neil', price: '' }] + }) + + try { + const text = await exportAs($table, 'sql') + + expect(text).toContain('(1, \'O\'\'Neil\', \'\')') + } finally { + destroy($table) + } + }) + + it('returns empty content instead of an invalid statement without rows', async () => { + const $table = await createTable({ data: [] }) + + try { + const text = await exportAs($table, 'sql') + + expect(text).toBe('') + expect(text).not.toContain('VALUES') + } finally { + destroy($table) + } + }) + }) + + it('exports excel as csv content with an xls extension', async () => { + const $table = await createTable() + + try { + const text = await exportAs($table, 'excel') + + expect(downloadName).toBe('table.xls') + expect(capturedBlob.type).toBe('application/vnd.ms-excel') + expect(text).toContain('ID,Name,Price\r\n1,Item 1,1.00') + } finally { + destroy($table) + } + }) + + describe('file name', () => { + it('accepts a string', async () => { + const $table = await createTable({ exportOptions: { fileName: 'report' } }) + + try { + await exportAs($table, 'csv') + + expect(downloadName).toBe('report.csv') + } finally { + destroy($table) + } + }) + + it('accepts a function', async () => { + const callArgs = [] + const $table = await createTable({ + exportOptions: { + fileName (...args) { + callArgs.push(args) + return 'custom-export-name' + } + } + }) + + try { + await exportAs($table, 'csv') + + expect(downloadName).toBe('custom-export-name.csv') + expect(callArgs).toHaveLength(1) + } finally { + destroy($table) + } + }) + + it('is overridden by the option passed to exportTable', async () => { + const $table = await createTable({ exportOptions: { fileName: 'report' } }) + + try { + await exportAs($table, 'csv', { fileName: 'override' }) + + expect(downloadName).toBe('override.csv') + } finally { + destroy($table) + } + }) + }) + + describe('exported columns', () => { + it('includes hidden columns with forceExport and excludes visible columns with forceHide', async () => { + const $table = await createTable({ + columns: [ + { field: 'id', title: 'ID' }, + { field: 'secret', title: 'Secret', visible: false, forceExport: true }, + { field: 'icon', title: 'Icon', forceHide: true }, + { field: 'name', title: 'Name' } + ], + data: [{ id: 1, secret: 'hidden value', icon: 'ignored', name: 'Item 1' }] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,Secret,Name') + expect(text).toContain('1,hidden value,Item 1') + expect(text).not.toContain('Icon') + expect(text).not.toContain('ignored') + } finally { + destroy($table) + } + }) + + it('excludes the checkbox column', async () => { + const $table = await createTable({ + columns: [ + { checkbox: true, field: 'state' }, + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,Name') + expect(text).not.toMatch(/^\uFEFF?state,/i) + } finally { + destroy($table) + } + }) + + it('excludes colspan columns without a field', async () => { + const $table = await createTable({ + columns: [ + [{ title: 'Group', colspan: 2 }, { title: 'Details', colspan: 1 }], + [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { field: 'price', title: 'Price' } + ] + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,Name,Price') + expect(text).not.toContain('Group') + expect(text).not.toContain('Details') + } finally { + destroy($table) + } + }) + }) + + describe('values', () => { + it('exports the formatted value with html tags stripped', async () => { + const $table = await createTable({ + columns: [ + { field: 'id', title: 'ID' }, + { + field: 'name', + title: 'Name', + formatter: value => `${value}` + }, + { field: 'price', title: 'Price' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('1,Item 1,1.00') + expect(text).not.toContain('span') + } finally { + destroy($table) + } + }) + + it('uses the visible text for titles when escapeTitle is enabled', async () => { + const $table = await createTable({ + escape: true, + escapeTitle: true, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'P & P' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,P & P') + expect(text).not.toContain('<b>') + } finally { + destroy($table) + } + }) + + it('strips html from titles when escapeTitle is disabled', async () => { + const $table = await createTable({ + escape: true, + escapeTitle: false, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,Name') + expect(text).not.toContain('') + } finally { + destroy($table) + } + }) + }) + + it('appends the footer row using the footer formatters', async () => { + const $table = await createTable({ + showFooter: true, + exportFooter: true, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' }, + { + field: 'price', + title: 'Price', + footerFormatter: (data, value) => `sum: ${value}` + } + ] + }) + + try { + const instance = $table.data('bootstrap.table') + + instance.footerData = [{ id: '', name: '', price: '3.00' }] + + const text = await exportAs($table, 'csv') + + expect(text.endsWith(',,sum: 3.00')).toBe(true) + } finally { + destroy($table) + } + }) + + describe('events', () => { + it('triggers export-started and export-saved', async () => { + const $table = await createTable() + + try { + let started = 0 + let savedPayload = null + + $table.on('export-started.bs.table', () => { + started += 1 + }) + $table.on('export-saved.bs.table', (_e, rows) => { + savedPayload = rows + }) + + await exportAs($table, 'csv') + + expect(started).toBe(1) + expect(savedPayload).toHaveLength(2) + } finally { + destroy($table) + } + }) + + it('does not export or trigger events for unsupported types', async () => { + const $table = await createTable() + + try { + let started = 0 + let saved = 0 + + $table.on('export-started.bs.table', () => { + started += 1 + }) + $table.on('export-saved.bs.table', () => { + saved += 1 + }) + + const result = $table.data('bootstrap.table').exportTable({ type: 'pdf' }) + + expect(result).toBe(false) + expect(started).toBe(0) + expect(saved).toBe(0) + expect(saveCount).toBe(0) + } finally { + destroy($table) + } + }) + + it('does not export when selected rows are required but none are selected', async () => { + const $table = await createTable({ exportDataType: 'selected' }) + + try { + let started = 0 + + $table.on('export-started.bs.table', () => { + started += 1 + }) + + const result = $table.data('bootstrap.table').exportTable({ type: 'csv' }) + + expect(result).toBe(false) + expect(started).toBe(0) + expect(saveCount).toBe(0) + } finally { + destroy($table) + } + }) + + it('exports only the selected rows', async () => { + const $table = await createTable({ + exportDataType: 'selected', + checkboxHeader: true, + columns: [ + { checkbox: true, field: 'state' }, + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ] + }) + + try { + const instance = $table.data('bootstrap.table') + + instance.check(1) + + const text = await exportAs($table, 'csv') + + expect(text).toContain('ID,Name\r\n2,Item 2') + expect(text).not.toContain('Item 1') + } finally { + destroy($table) + } + }) + }) + + it('exports the current page only for the basic data type', async () => { + const $table = await createTable({ + pagination: true, + pageSize: 2, + data: [ + { id: 1, name: 'Item 1', price: '1.00' }, + { id: 2, name: 'Item 2', price: '2.00' }, + { id: 3, name: 'Item 3', price: '3.00' }, + { id: 4, name: 'Item 4', price: '4.00' }, + { id: 5, name: 'Item 5', price: '5.00' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('1,Item 1,1.00') + expect(text).toContain('2,Item 2,2.00') + expect(text).not.toContain('Item 3') + } finally { + destroy($table) + } + }) + + it('exports all pages with the all data type', async () => { + const $table = await createTable({ + exportDataType: 'all', + pagination: true, + pageSize: 2, + data: [ + { id: 1, name: 'Item 1', price: '1.00' }, + { id: 2, name: 'Item 2', price: '2.00' }, + { id: 3, name: 'Item 3', price: '3.00' } + ] + }) + + try { + const text = await exportAs($table, 'csv') + + expect(text).toContain('1,Item 1,1.00') + expect(text).toContain('3,Item 3,3.00') + } finally { + destroy($table) + } + }) +})