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:
ZDDC 2026-05-19 07:00:23 -05:00
parent 73e34bed5e
commit 847e082e6e
5 changed files with 199 additions and 1 deletions

View file

@ -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
View 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);

View file

@ -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;

View file

@ -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>

View file

@ -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;