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/save.js" \
|
||||||
"js/row-ops.js" \
|
"js/row-ops.js" \
|
||||||
"js/clipboard.js" \
|
"js/clipboard.js" \
|
||||||
|
"js/export.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
"../form/js/app.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 countEl = document.getElementById('table-rowcount');
|
||||||
const clearBtn = document.getElementById('table-clear-filters');
|
const clearBtn = document.getElementById('table-clear-filters');
|
||||||
const addRowBtn = document.getElementById('table-add-row');
|
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
|
// Add-row button: appends a draft row inline. Save fires on
|
||||||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||||||
|
|
@ -38,6 +39,23 @@
|
||||||
// context loaded with columns) — the test-fixture inline-context
|
// context loaded with columns) — the test-fixture inline-context
|
||||||
// harness opens tables.html directly with no URL shape, so we
|
// harness opens tables.html directly with no URL shape, so we
|
||||||
// gate on having a column list AND running over http(s).
|
// 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) {
|
if (addRowBtn) {
|
||||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
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>
|
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-toolbar__right">
|
<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>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1511,7 +1511,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-toolbar__right">
|
<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>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -6095,6 +6096,86 @@ body.is-elevated::after {
|
||||||
};
|
};
|
||||||
})(window.tablesApp);
|
})(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) {
|
(function (app) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -6225,6 +6306,7 @@ body.is-elevated::after {
|
||||||
const countEl = document.getElementById('table-rowcount');
|
const countEl = document.getElementById('table-rowcount');
|
||||||
const clearBtn = document.getElementById('table-clear-filters');
|
const clearBtn = document.getElementById('table-clear-filters');
|
||||||
const addRowBtn = document.getElementById('table-add-row');
|
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
|
// Add-row button: appends a draft row inline. Save fires on
|
||||||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
// 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
|
// context loaded with columns) — the test-fixture inline-context
|
||||||
// harness opens tables.html directly with no URL shape, so we
|
// harness opens tables.html directly with no URL shape, so we
|
||||||
// gate on having a column list AND running over http(s).
|
// 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) {
|
if (addRowBtn) {
|
||||||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue