From f3bc0b45520b8511613f61f9db7e447c863264da Mon Sep 17 00:00:00 2001 From: helloJetBase-tech <178346048+marktech0813@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:42:58 +0200 Subject: [PATCH 1/7] Update CONTRIBUTING.md Updated CONTRIBUTING.md to reflect that Table Export is now built-in. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 36f2377bdd7af5f823268fe65e62a2ca0580519b Mon Sep 17 00:00:00 2001 From: helloJetBase-tech <178346048+marktech0813@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:44:20 +0200 Subject: [PATCH 2/7] Update export.mdx Updated docs in site/src/pages/docs/extensions/export.mdx: - Removed plugin requirement. - Documented built-in options (fileName, csvDelimiter, tableName) and supported types. --- site/src/pages/docs/extensions/export.mdx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/pages/docs/extensions/export.mdx b/site/src/pages/docs/extensions/export.mdx index fa0ca21fe1..e33439c496 100644 --- a/site/src/pages/docs/extensions/export.mdx +++ b/site/src/pages/docs/extensions/export.mdx @@ -6,9 +6,7 @@ group: extensions toc: true --- -Use Plugin: [tableExport.jquery.plugin](https://github.com/hhurz/tableExport.jquery.plugin) - -This is an important link to check out as some file types may require extra steps. +This extension now uses a built-in exporter (no external plugin required). ## Usage @@ -66,7 +64,10 @@ 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 returning the base filename (without extension). + - `csvDelimiter`: CSV delimiter (default `,`). + - `tableName`: Table name used for SQL/XML exports (default `table`). `exportOptions.fileName` can be a string or a function, for example: @@ -86,7 +87,8 @@ 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. - **Default:** `['json', 'xml', 'csv', 'txt', 'sql', 'excel']` From 0db80529d31972ce007767cb496839c5f7b39795 Mon Sep 17 00:00:00 2001 From: helloJetBase-tech <178346048+marktech0813@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:46:35 +0200 Subject: [PATCH 3/7] Update bootstrap-table-export.js Implemented a built-in exporter in src/extensions/export/bootstrap-table-export.js that: - Supports types: json, xml, csv, txt, sql, and maps excel to CSV (.xls). - Preserves existing behaviors: fileName function support, footer export, column visibility (forceExport/forceHide), selected/all/basic export, and detail-view column exclusion via ignoreColumn. - Uses safe Blob download, avoiding deprecated/vulnerable plugin code. Contribution by Gittensor, learn more at https://gittensor.io/ --- .../export/bootstrap-table-export.js | 185 +++++++++++++++--- 1 file changed, 162 insertions(+), 23 deletions(-) diff --git a/src/extensions/export/bootstrap-table-export.js b/src/extensions/export/bootstrap-table-export.js index a8560bb58b..f57dfd2b12 100644 --- a/src/extensions/export/bootstrap-table-export.js +++ b/src/extensions/export/bootstrap-table-export.js @@ -1,6 +1,6 @@ /** * @author zhixin wen - * extensions: https://github.com/hhurz/tableExport.jquery.plugin + * Built-in export implementation (replacing deprecated tableExport.jquery.plugin) */ const Utils = $.fn.bootstrapTable.utils @@ -179,6 +179,129 @@ $.BootstrapTable = class extends $.BootstrapTable { const stateField = this.header.stateField const isCardView = o.cardView + // Helper: escape CSV field + const csvEscape = (value, delimiter) => { + const d = delimiter || ',' + if (value == null) return '' + const str = String(value) + const mustQuote = str.includes(d) || str.includes('"') || str.includes('\n') || str.includes('\r') + if (!mustQuote) return str + return `"${str.replace(/"/g, '""')}"` + } + + // Helper: build a 2D array of visible table content from DOM + const collectTableMatrix = (ignoreColumnIdxs) => { + const ignore = new Set((ignoreColumnIdxs || []).map(i => Number(i))) + const headerRow = [] + const headerTr = this.$el.find('thead tr').last() + const $ths = headerTr.children() + $ths.each((idx, th) => { + if ($(th).is(':visible') && !ignore.has(idx)) { + headerRow.push($(th).text().trim()) + } + }) + + const rows = [] + const $trs = this.$el.find('tbody > tr:visible') + $trs.each((_, tr) => { + // skip detail view rows or non-data rows + const $tr = $(tr) + if ($tr.hasClass('detail-view') || $tr.hasClass('no-records-found')) return + const row = [] + $tr.children().each((idx, td) => { + if ($(td).is(':visible') && !ignore.has(idx)) { + row.push($(td).text().trim()) + } + }) + if (row.length) rows.push(row) + }) + return { headerRow, rows } + } + + // Helper: convert to content by type + const buildFile = (type, matrix, exportOptions) => { + const delimiter = exportOptions && exportOptions.csvDelimiter ? exportOptions.csvDelimiter : ',' + const tableName = (exportOptions && exportOptions.tableName) || 'table' + const { headerRow, rows } = matrix + switch (type) { + case 'csv': { + const lines = [] + if (headerRow.length) lines.push(headerRow.map(h => csvEscape(h, delimiter)).join(delimiter)) + rows.forEach(r => lines.push(r.map(v => csvEscape(v, delimiter)).join(delimiter))) + return { mime: 'text/csv;charset=utf-8', ext: 'csv', content: lines.join('\r\n') } + } + case 'txt': { + const d = '\t' + const lines = [] + if (headerRow.length) lines.push(headerRow.join(d)) + rows.forEach(r => lines.push(r.join(d))) + return { mime: 'text/plain;charset=utf-8', ext: 'txt', content: lines.join('\r\n') } + } + case 'json': { + const objs = [] + if (headerRow.length) { + rows.forEach(r => { + const obj = {} + headerRow.forEach((h, i) => { obj[h || `col${i + 1}`] = r[i] }) + objs.push(obj) + }) + } else { + rows.forEach(r => objs.push(r)) + } + return { mime: 'application/json;charset=utf-8', ext: 'json', content: JSON.stringify(objs, null, 2) } + } + case 'xml': { + const esc = s => String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + const lines = ['', `<${tableName}>`] + rows.forEach(r => { + lines.push(' ') + r.forEach((v, i) => { + const key = headerRow[i] || `col${i + 1}` + lines.push(` <${key}>${esc(v)}`) + }) + lines.push(' ') + }) + lines.push(``) + return { mime: 'application/xml;charset=utf-8', ext: 'xml', content: lines.join('\n') } + } + case 'sql': { + const cols = headerRow.map(h => (h || '').replace(/`/g, '``') || null).map((c, i) => c || `col${i + 1}`) + const values = rows.map(r => '(' + r.map(v => { + if (v == null || v === '') return 'NULL' + const s = String(v).replace(/'/g, "''") + return `'${s}'` + }).join(', ') + ')') + const sql = `INSERT INTO \`${tableName}\` (\`${cols.join('`, `')}\`) VALUES\n${values.join(',\n')};` + return { mime: 'application/sql;charset=utf-8', ext: 'sql', content: sql } + } + case 'excel': { + // Map to CSV for compatibility with legacy default 'excel' option + const csv = buildFile('csv', matrix, exportOptions) + return { mime: 'application/vnd.ms-excel', ext: 'xls', content: csv.content } + } + default: + return null + } + } + + // Helper: trigger browser download + 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) + } + const doExport = callback => { if (stateField) { this.hideColumn(stateField) @@ -235,34 +358,50 @@ $.BootstrapTable = class extends $.BootstrapTable { options.fileName = o.exportOptions.fileName() } - this.$el.tableExport(Utils.extend({ - onAfterSaveToFile: () => { - if (o.exportFooter) { - this.load(data) - } + // Build content and save without external plugin + const effective = Utils.extend({}, o.exportOptions, options) + const type = (effective.type || 'csv').toLowerCase() + const ignoreIdx = effective.ignoreColumn + const matrix = collectTableMatrix(ignoreIdx) + const built = buildFile(type, matrix, effective) + + const onAfter = () => { + if (o.exportFooter) { + this.load(data) + } - if (stateField) { - this.showColumn(stateField) + if (stateField) { + this.showColumn(stateField) + } + if (isCardView) { + this.toggleView() + } + + hiddenColumns.forEach(row => { + if (row.forceExport) { + this.hideColumn(row.field) } - if (isCardView) { - this.toggleView() + }) + + this.columns.forEach(row => { + if (row.forceHide) { + this.showColumn(row.field) } + }) - hiddenColumns.forEach(row => { - if (row.forceExport) { - this.hideColumn(row.field) - } - }) + if (callback) callback() + } - this.columns.forEach(row => { - if (row.forceHide) { - this.showColumn(row.field) - } - }) + if (!built) { + // unsupported type without deprecated plugin; no-op but still restore state + onAfter() + return + } - if (callback) callback() - } - }, o.exportOptions, options)) + const fileBase = effective.fileName || 'table-export' + const filename = `${fileBase}.${built.ext}` + saveAs(built.content, built.mime, filename) + onAfter() } if (o.exportDataType === 'all' && o.pagination) { From be887c3484a1e2c53e77b4a55631d947bf2b4717 Mon Sep 17 00:00:00 2001 From: helloJetBase-tech <178346048+marktech0813@users.noreply.github.com> Date: Wed, 19 Nov 2025 07:35:48 +0200 Subject: [PATCH 4/7] Fix issues table export.jquery.plugin deprecated #8070 #8071 Refactor CSV escaping and table matrix collection logic for improved readability and null handling. - Using strict equality checks for null/undefined. - Adding required blank lines between declarations and control statements. - Removing unnecessary arrow parens and extra parentheses. - Using template literals instead of string concatenation. - Ensuring single quotes and proper brace style. - Splitting multi-statements onto separate lines. --- .../export/bootstrap-table-export.js | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/extensions/export/bootstrap-table-export.js b/src/extensions/export/bootstrap-table-export.js index f57dfd2b12..09ef07093e 100644 --- a/src/extensions/export/bootstrap-table-export.js +++ b/src/extensions/export/bootstrap-table-export.js @@ -182,19 +182,23 @@ $.BootstrapTable = class extends $.BootstrapTable { // Helper: escape CSV field const csvEscape = (value, delimiter) => { const d = delimiter || ',' - if (value == null) return '' + + if (value === null || value === undefined) return '' + const str = String(value) const mustQuote = str.includes(d) || str.includes('"') || str.includes('\n') || str.includes('\r') + if (!mustQuote) return str return `"${str.replace(/"/g, '""')}"` } // Helper: build a 2D array of visible table content from DOM - const collectTableMatrix = (ignoreColumnIdxs) => { - const ignore = new Set((ignoreColumnIdxs || []).map(i => Number(i))) + const collectTableMatrix = ignoreColumnIdxs => { + const ignore = new Set((ignoreColumnIdxs ? ignoreColumnIdxs.map(Number) : [])) const headerRow = [] const headerTr = this.$el.find('thead tr').last() const $ths = headerTr.children() + $ths.each((idx, th) => { if ($(th).is(':visible') && !ignore.has(idx)) { headerRow.push($(th).text().trim()) @@ -203,61 +207,85 @@ $.BootstrapTable = class extends $.BootstrapTable { const rows = [] const $trs = this.$el.find('tbody > tr:visible') + $trs.each((_, tr) => { // skip detail view rows or non-data rows const $tr = $(tr) + if ($tr.hasClass('detail-view') || $tr.hasClass('no-records-found')) return + const row = [] $tr.children().each((idx, td) => { if ($(td).is(':visible') && !ignore.has(idx)) { row.push($(td).text().trim()) } }) - if (row.length) rows.push(row) + + if (row.length) { + rows.push(row) + } }) + return { headerRow, rows } } // Helper: convert to content by type const buildFile = (type, matrix, exportOptions) => { const delimiter = exportOptions && exportOptions.csvDelimiter ? exportOptions.csvDelimiter : ',' - const tableName = (exportOptions && exportOptions.tableName) || 'table' + const tableName = exportOptions && exportOptions.tableName || 'table' const { headerRow, rows } = matrix + switch (type) { case 'csv': { const lines = [] - if (headerRow.length) lines.push(headerRow.map(h => csvEscape(h, delimiter)).join(delimiter)) + + if (headerRow.length) { + lines.push(headerRow.map(h => csvEscape(h, delimiter)).join(delimiter)) + } + rows.forEach(r => lines.push(r.map(v => csvEscape(v, delimiter)).join(delimiter))) return { mime: 'text/csv;charset=utf-8', ext: 'csv', content: lines.join('\r\n') } } case 'txt': { const d = '\t' const lines = [] - if (headerRow.length) lines.push(headerRow.join(d)) + + if (headerRow.length) { + lines.push(headerRow.join(d)) + } + rows.forEach(r => lines.push(r.join(d))) return { mime: 'text/plain;charset=utf-8', ext: 'txt', content: lines.join('\r\n') } } case 'json': { const objs = [] + if (headerRow.length) { rows.forEach(r => { const obj = {} - headerRow.forEach((h, i) => { obj[h || `col${i + 1}`] = r[i] }) + + headerRow.forEach((h, i) => { + obj[h || `col${i + 1}`] = r[i] + }) + objs.push(obj) }) } else { rows.forEach(r => objs.push(r)) } + return { mime: 'application/json;charset=utf-8', ext: 'json', content: JSON.stringify(objs, null, 2) } } case 'xml': { - const esc = s => String(s == null ? '' : s) + const esc = s => String(s === null || s === undefined ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') + const lines = ['', `<${tableName}>`] + rows.forEach(r => { lines.push(' ') r.forEach((v, i) => { @@ -266,16 +294,19 @@ $.BootstrapTable = class extends $.BootstrapTable { }) lines.push(' ') }) + lines.push(``) return { mime: 'application/xml;charset=utf-8', ext: 'xml', content: lines.join('\n') } } case 'sql': { const cols = headerRow.map(h => (h || '').replace(/`/g, '``') || null).map((c, i) => c || `col${i + 1}`) - const values = rows.map(r => '(' + r.map(v => { - if (v == null || v === '') return 'NULL' - const s = String(v).replace(/'/g, "''") + + const values = rows.map(r => `(${r.map(v => { + if (v === null || v === undefined || v === '') return 'NULL' + const s = String(v).replace(/'/g, '\'\'' ) return `'${s}'` - }).join(', ') + ')') + }).join(', ')})`) + const sql = `INSERT INTO \`${tableName}\` (\`${cols.join('`, `')}\`) VALUES\n${values.join(',\n')};` return { mime: 'application/sql;charset=utf-8', ext: 'sql', content: sql } } @@ -306,6 +337,7 @@ $.BootstrapTable = class extends $.BootstrapTable { if (stateField) { this.hideColumn(stateField) } + if (isCardView) { this.toggleView() } @@ -373,6 +405,7 @@ $.BootstrapTable = class extends $.BootstrapTable { if (stateField) { this.showColumn(stateField) } + if (isCardView) { this.toggleView() } @@ -401,6 +434,7 @@ $.BootstrapTable = class extends $.BootstrapTable { const fileBase = effective.fileName || 'table-export' const filename = `${fileBase}.${built.ext}` saveAs(built.content, built.mime, filename) + onAfter() } From 084348b4f129d3a567b0fd66a690c313d65ba390 Mon Sep 17 00:00:00 2001 From: zhixin Date: Sun, 25 Oct 2026 12:00:43 +0800 Subject: [PATCH 5/7] Replace DOM-based export with data-API driven export (#8071) Collect export data from the table data model instead of scraping the rendered DOM, so the exported values match what is rendered by the table formatters while keeping the original value types available. - validate the export type up front and skip unsupported ones without triggering any export event - export the current page only for the basic data type - sanitize xml element names and escape sql identifiers - return empty sql content instead of an invalid INSERT statement Co-authored-by: marktech0813 <178346048+marktech0813@users.noreply.github.com> --- .cspell-words.txt | 1 + .../export/bootstrap-table-export.js | 591 +++++++++--------- 2 files changed, 292 insertions(+), 300 deletions(-) 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/src/extensions/export/bootstrap-table-export.js b/src/extensions/export/bootstrap-table-export.js index d4cdf0b9f0..05d0623b05 100644 --- a/src/extensions/export/bootstrap-table-export.js +++ b/src/extensions/export/bootstrap-table-export.js @@ -3,26 +3,219 @@ * 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,335 +366,133 @@ $.BootstrapTable = class extends $.BootstrapTable { } } - exportTable (options) { - const o = this.options - const stateField = this.header.stateField - const isCardView = o.cardView - - // Helper: escape CSV field - const csvEscape = (value, delimiter) => { - const d = delimiter || ',' - - if (value === null || value === undefined) return '' - - const str = String(value) - const mustQuote = str.includes(d) || str.includes('"') || str.includes('\n') || str.includes('\r') - - if (!mustQuote) return str - return `"${str.replace(/"/g, '""')}"` - } - - // Helper: build a 2D array of visible table content from DOM - const collectTableMatrix = ignoreColumnIdxs => { - const ignore = new Set((ignoreColumnIdxs ? ignoreColumnIdxs.map(Number) : [])) - const headerRow = [] - const headerTr = this.$el.find('thead tr').last() - const $ths = headerTr.children() - - $ths.each((idx, th) => { - if ($(th).is(':visible') && !ignore.has(idx)) { - headerRow.push($(th).text().trim()) - } - }) - - const rows = [] - const $trs = this.$el.find('tbody > tr:visible') - - $trs.each((_, tr) => { - // skip detail view rows or non-data rows - const $tr = $(tr) - - if ($tr.hasClass('detail-view') || $tr.hasClass('no-records-found')) return - - const row = [] - $tr.children().each((idx, td) => { - if ($(td).is(':visible') && !ignore.has(idx)) { - row.push($(td).text().trim()) - } - }) - - if (row.length) { - rows.push(row) - } - }) - - return { headerRow, rows } - } - - // Helper: convert to content by type - const buildFile = (type, matrix, exportOptions) => { - const delimiter = exportOptions && exportOptions.csvDelimiter ? exportOptions.csvDelimiter : ',' - const tableName = exportOptions && exportOptions.tableName || 'table' - const { headerRow, rows } = matrix - - switch (type) { - case 'csv': { - const lines = [] - - if (headerRow.length) { - lines.push(headerRow.map(h => csvEscape(h, delimiter)).join(delimiter)) - } - - rows.forEach(r => lines.push(r.map(v => csvEscape(v, delimiter)).join(delimiter))) - return { mime: 'text/csv;charset=utf-8', ext: 'csv', content: lines.join('\r\n') } - } - case 'txt': { - const d = '\t' - const lines = [] - - if (headerRow.length) { - lines.push(headerRow.join(d)) - } - - rows.forEach(r => lines.push(r.join(d))) - return { mime: 'text/plain;charset=utf-8', ext: 'txt', content: lines.join('\r\n') } - } - case 'json': { - const objs = [] - - if (headerRow.length) { - rows.forEach(r => { - const obj = {} - - headerRow.forEach((h, i) => { - obj[h || `col${i + 1}`] = r[i] - }) - - objs.push(obj) - }) - } else { - rows.forEach(r => objs.push(r)) - } - - return { mime: 'application/json;charset=utf-8', ext: 'json', content: JSON.stringify(objs, null, 2) } - } - case 'xml': { - const esc = s => String(s === null || s === undefined ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - - const lines = ['', `<${tableName}>`] - - rows.forEach(r => { - lines.push(' ') - r.forEach((v, i) => { - const key = headerRow[i] || `col${i + 1}` - lines.push(` <${key}>${esc(v)}`) - }) - lines.push(' ') - }) - - lines.push(``) - return { mime: 'application/xml;charset=utf-8', ext: 'xml', content: lines.join('\n') } - } - case 'sql': { - const cols = headerRow.map(h => (h || '').replace(/`/g, '``') || null).map((c, i) => c || `col${i + 1}`) - - const values = rows.map(r => `(${r.map(v => { - if (v === null || v === undefined || v === '') return 'NULL' - const s = String(v).replace(/'/g, '\'\'' ) - return `'${s}'` - }).join(', ')})`) - - const sql = `INSERT INTO \`${tableName}\` (\`${cols.join('`, `')}\`) VALUES\n${values.join(',\n')};` - return { mime: 'application/sql;charset=utf-8', ext: 'sql', content: sql } - } - case 'excel': { - // Map to CSV for compatibility with legacy default 'excel' option - const csv = buildFile('csv', matrix, exportOptions) - return { mime: 'application/vnd.ms-excel', ext: 'xls', content: csv.content } - } - default: - return null - } - } - - // Helper: trigger browser download - 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) - } - - 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() - - hiddenColumns.forEach(row => { - if (row.forceExport) { - this.showColumn(row.field) - } - }) - - if (typeof o.exportOptions.fileName === 'function') { - options.fileName = o.exportOptions.fileName() - } - - // Build content and save without external plugin - const effective = Utils.extend({}, o.exportOptions, options) - const type = (effective.type || 'csv').toLowerCase() - const ignoreIdx = effective.ignoreColumn - const matrix = collectTableMatrix(ignoreIdx) - const built = buildFile(type, matrix, effective) - - const onAfter = () => { - if (o.exportFooter) { - this.load(data) - } - - if (stateField) { - this.showColumn(stateField) - } - - if (isCardView) { - this.toggleView() - } + return { columns, rows: textRows, values: rows } + } - hiddenColumns.forEach(row => { - if (row.forceExport) { - this.hideColumn(row.field) - } - }) + exportTable (options) { + const o = this.options + let selectedData = null - this.columns.forEach(row => { - if (row.forceHide) { - this.showColumn(row.field) - } - }) + // Early return for selected data type when no rows are selected + if (o.exportDataType === 'selected') { + selectedData = this.getSelections() - if (callback) callback() + if (!selectedData.length) { + return false } + } - if (!built) { - // unsupported type without deprecated plugin; no-op but still restore state - onAfter() - return - } + const exportOptions = Utils.extend({}, o.exportOptions, options) + const type = String(exportOptions.type || o.exportTypes[0] || 'csv').toLowerCase() - const fileBase = effective.fileName || 'table-export' - const filename = `${fileBase}.${built.ext}` - saveAs(built.content, built.mime, filename) - - onAfter() + 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 () { From ed0f6c1ff82f96b0459004cd3b581ea41c51a2b0 Mon Sep 17 00:00:00 2001 From: zhixin Date: Sun, 25 Oct 2026 12:00:55 +0800 Subject: [PATCH 6/7] Add export extension unit tests and rewrite export cypress tests (#8071) Cover the built-in exporter with unit tests through a mocked download and rewrite the cypress export tests, which still asserted that the removed tableExport.jquery.plugin was called. Co-authored-by: marktech0813 <178346048+marktech0813@users.noreply.github.com> --- cypress/extensions/export/options.js | 103 +++-- tests/extensions/export.test.js | 649 +++++++++++++++++++++++++++ 2 files changed, 718 insertions(+), 34 deletions(-) create mode 100644 tests/extensions/export.test.js 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/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) + } + }) +}) From 11afa343bce2ce244dd2fe79011b46f555b8ffc8 Mon Sep 17 00:00:00 2001 From: zhixin Date: Sun, 25 Oct 2026 12:00:55 +0800 Subject: [PATCH 7/7] Update export extension docs and sync Chinese translation (#8071) Document the exportOptions supported by the built-in exporter, the removed plugin options and types, the basic data type exporting the current page and the excel type generating CSV content with an xls extension. Co-authored-by: marktech0813 <178346048+marktech0813@users.noreply.github.com> --- site/src/pages/docs/extensions/export.mdx | 45 ++++++++++++++-- .../pages/zh-cn/docs/extensions/export.mdx | 53 +++++++++++++++---- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/site/src/pages/docs/extensions/export.mdx b/site/src/pages/docs/extensions/export.mdx index e33439c496..d4fdbd0e0e 100644 --- a/site/src/pages/docs/extensions/export.mdx +++ b/site/src/pages/docs/extensions/export.mdx @@ -8,6 +8,15 @@ toc: true This extension now uses a built-in exporter (no external plugin required). +> **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 ```html @@ -42,6 +51,14 @@ This extension now uses a built-in exporter (no external plugin required). 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 @@ -52,7 +69,8 @@ This extension now uses a built-in exporter (no external plugin required). - **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` @@ -65,9 +83,16 @@ This extension now uses a built-in exporter (no external plugin required). - **Detail:** Options for the built-in exporter: - - `fileName`: String or function returning the base filename (without extension). - - `csvDelimiter`: CSV delimiter (default `,`). - - `tableName`: Table name used for SQL/XML exports (default `table`). + + - `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: @@ -90,6 +115,18 @@ This extension now uses a built-in exporter (no external plugin required). 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']` ### Icons 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']`