feat(tables): Export CSV button in the table toolbar
Client-side download of the current view — filter + sort + column order match what's on screen, values pass through util.formatCell so dates / numbers / booleans render the same way they do in cells. RFC 4180 quoting; UTF-8 BOM so Excel detects encoding without an import wizard. Sits next to "+ Add row" and shows for every table that loaded with columns (no HTTP gate — the data is already in the client), so MDL, RSK, SSR, and both project-level rollups all get the affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73e34bed5e
commit
847e082e6e
5 changed files with 199 additions and 1 deletions
|
|
@ -55,6 +55,7 @@ concat_files \
|
|||
"js/save.js" \
|
||||
"js/row-ops.js" \
|
||||
"js/clipboard.js" \
|
||||
"js/export.js" \
|
||||
"js/render.js" \
|
||||
"js/main.js" \
|
||||
"../form/js/app.js" \
|
||||
|
|
|
|||
79
tables/js/export.js
Normal file
79
tables/js/export.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// export.js — CSV download of the current table view.
|
||||
//
|
||||
// Exports what the user sees: filter + sort applied, columns in the
|
||||
// order declared by the spec. Values pass through util.formatCell so
|
||||
// date / number / boolean cells match their on-screen rendering.
|
||||
// RFC 4180 quoting (double-quote any cell with a comma, newline, or
|
||||
// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
|
||||
// Excel detects the encoding without a manual import-wizard step.
|
||||
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function csvEscape(value) {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (/[",\r\n]/.test(str)) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function buildCsv(rows, columns, util) {
|
||||
const lines = [];
|
||||
lines.push(columns.map(function (c) {
|
||||
return csvEscape(c.title || c.field || '');
|
||||
}).join(','));
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cells = columns.map(function (c) {
|
||||
const raw = util.resolveField(row.data, c.field);
|
||||
return csvEscape(util.formatCell(raw, c.format));
|
||||
});
|
||||
lines.push(cells.join(','));
|
||||
}
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function suggestFilename() {
|
||||
const titleEl = document.getElementById('table-title');
|
||||
const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
|
||||
const base = raw.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'table';
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return base + '-' + stamp + '.csv';
|
||||
}
|
||||
|
||||
function download(csv, filename) {
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
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);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
function invoke() {
|
||||
const ctx = app.context || {};
|
||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||
if (columns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const state = app.state;
|
||||
const util = app.modules.util;
|
||||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
|
||||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
|
||||
const csv = buildCsv(sorted, columns, util);
|
||||
download(csv, suggestFilename());
|
||||
}
|
||||
|
||||
app.modules.exportCsv = {
|
||||
invoke: invoke,
|
||||
buildCsv: buildCsv,
|
||||
csvEscape: csvEscape
|
||||
};
|
||||
})(window.tablesApp);
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
const countEl = document.getElementById('table-rowcount');
|
||||
const clearBtn = document.getElementById('table-clear-filters');
|
||||
const addRowBtn = document.getElementById('table-add-row');
|
||||
const exportBtn = document.getElementById('table-export-csv');
|
||||
|
||||
// Add-row button: appends a draft row inline. Save fires on
|
||||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||||
|
|
@ -38,6 +39,23 @@
|
|||
// context loaded with columns) — the test-fixture inline-context
|
||||
// harness opens tables.html directly with no URL shape, so we
|
||||
// gate on having a column list AND running over http(s).
|
||||
// Export CSV: client-side build of the current view (filtered +
|
||||
// sorted columns + values). No server round-trip, no auth gate
|
||||
// — the user already has the data on screen. Shown on every
|
||||
// table that loaded with columns, regardless of HTTP/file://.
|
||||
if (exportBtn) {
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
if (hasCols) {
|
||||
exportBtn.hidden = false;
|
||||
exportBtn.addEventListener('click', function () {
|
||||
const exp = app.modules.exportCsv;
|
||||
if (exp && typeof exp.invoke === 'function') {
|
||||
exp.invoke();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addRowBtn) {
|
||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||
</div>
|
||||
<div class="table-toolbar__right">
|
||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1511,7 +1511,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 02:25:26 · da4754b-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 11:59:55 · 73e34be-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -1536,6 +1536,7 @@ body.is-elevated::after {
|
|||
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||
</div>
|
||||
<div class="table-toolbar__right">
|
||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -6095,6 +6096,86 @@ body.is-elevated::after {
|
|||
};
|
||||
})(window.tablesApp);
|
||||
|
||||
// export.js — CSV download of the current table view.
|
||||
//
|
||||
// Exports what the user sees: filter + sort applied, columns in the
|
||||
// order declared by the spec. Values pass through util.formatCell so
|
||||
// date / number / boolean cells match their on-screen rendering.
|
||||
// RFC 4180 quoting (double-quote any cell with a comma, newline, or
|
||||
// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
|
||||
// Excel detects the encoding without a manual import-wizard step.
|
||||
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
function csvEscape(value) {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (/[",\r\n]/.test(str)) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function buildCsv(rows, columns, util) {
|
||||
const lines = [];
|
||||
lines.push(columns.map(function (c) {
|
||||
return csvEscape(c.title || c.field || '');
|
||||
}).join(','));
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const cells = columns.map(function (c) {
|
||||
const raw = util.resolveField(row.data, c.field);
|
||||
return csvEscape(util.formatCell(raw, c.format));
|
||||
});
|
||||
lines.push(cells.join(','));
|
||||
}
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function suggestFilename() {
|
||||
const titleEl = document.getElementById('table-title');
|
||||
const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
|
||||
const base = raw.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'table';
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return base + '-' + stamp + '.csv';
|
||||
}
|
||||
|
||||
function download(csv, filename) {
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
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);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
function invoke() {
|
||||
const ctx = app.context || {};
|
||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||
if (columns.length === 0) {
|
||||
return;
|
||||
}
|
||||
const state = app.state;
|
||||
const util = app.modules.util;
|
||||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
|
||||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
|
||||
const csv = buildCsv(sorted, columns, util);
|
||||
download(csv, suggestFilename());
|
||||
}
|
||||
|
||||
app.modules.exportCsv = {
|
||||
invoke: invoke,
|
||||
buildCsv: buildCsv,
|
||||
csvEscape: csvEscape
|
||||
};
|
||||
})(window.tablesApp);
|
||||
|
||||
(function (app) {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -6225,6 +6306,7 @@ body.is-elevated::after {
|
|||
const countEl = document.getElementById('table-rowcount');
|
||||
const clearBtn = document.getElementById('table-clear-filters');
|
||||
const addRowBtn = document.getElementById('table-add-row');
|
||||
const exportBtn = document.getElementById('table-export-csv');
|
||||
|
||||
// Add-row button: appends a draft row inline. Save fires on
|
||||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||||
|
|
@ -6233,6 +6315,23 @@ body.is-elevated::after {
|
|||
// context loaded with columns) — the test-fixture inline-context
|
||||
// harness opens tables.html directly with no URL shape, so we
|
||||
// gate on having a column list AND running over http(s).
|
||||
// Export CSV: client-side build of the current view (filtered +
|
||||
// sorted columns + values). No server round-trip, no auth gate
|
||||
// — the user already has the data on screen. Shown on every
|
||||
// table that loaded with columns, regardless of HTTP/file://.
|
||||
if (exportBtn) {
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
if (hasCols) {
|
||||
exportBtn.hidden = false;
|
||||
exportBtn.addEventListener('click', function () {
|
||||
const exp = app.modules.exportCsv;
|
||||
if (exp && typeof exp.invoke === 'function') {
|
||||
exp.invoke();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (addRowBtn) {
|
||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue