feat(tables): "Add from archive" on the project MDL rollup
The MDL owns the workflow of registering deliverables; this is the
catch-up path for files that already exist in the archive but were never
listed. On the project MDL rollup (<project>/mdl/, addable:false), a new
"+ From archive" toolbar button opens an overlay that walks the project
archive into the shared seltable (per-column autofilter + ctrl-shift
selection), dedupes the selection to one deliverable per tracking number,
and PUTs a deliverable .yaml into each originator's archive/<originator>/
mdl/. Identity fields are split positionally from the tracking number per
the project's own table columns (originator is folder-pinned, so omitted
from the body); the server composes/validates the filename. Existing
deliverables are skipped; created/skipped/failed are reported.
- tables/js/mdl-from-archive.js: walkArchive / dedupe / deliverableFromFile
/ instantiateOne + the overlay UI; setup() shows the button only on an
/mdl/ rollup over http, gated on archive create permission.
- shared/seltable.css: promoted seltable base styles + per-column filter
row + the overlay chrome (bundled into tables; classifier keeps its
inline copy).
- main.js wires setup(ctx); template.html adds the (hidden) button;
build.sh bundles ../shared/seltable.{js,css} + the new module.
- tests/tables-mdl.spec.js (new project): split/dedupe/walk/instantiate
against in-page mock FS handles; 7 green. tables suite still 47 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d4d48cad4a
commit
95c9e42270
7 changed files with 437 additions and 0 deletions
|
|
@ -95,6 +95,10 @@ export default defineConfig({
|
||||||
name: 'tables',
|
name: 'tables',
|
||||||
testMatch: 'tables.spec.js',
|
testMatch: 'tables.spec.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'tables-mdl',
|
||||||
|
testMatch: 'tables-mdl.spec.js',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'zddc-filter',
|
name: 'zddc-filter',
|
||||||
testMatch: 'zddc-filter.spec.js',
|
testMatch: 'zddc-filter.spec.js',
|
||||||
|
|
|
||||||
46
shared/seltable.css
Normal file
46
shared/seltable.css
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
|
||||||
|
Used by the tables tool's "Add from archive". The classifier carries an
|
||||||
|
equivalent copy inline in its layout.css for the catalog. */
|
||||||
|
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||||
|
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||||
|
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||||
|
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
|
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||||
|
.seltable__table thead th {
|
||||||
|
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||||
|
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
||||||
|
.seltable__colfilter {
|
||||||
|
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.seltable__row { cursor: pointer; user-select: none; }
|
||||||
|
.seltable__row:hover { background: var(--bg-hover); }
|
||||||
|
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
||||||
|
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
||||||
|
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
|
||||||
|
.mdlarch-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.mdlarch-overlay__box {
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
width: min(960px, 95vw); height: min(80vh, 760px);
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
|
||||||
|
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
|
||||||
|
.mdlarch-overlay__close:hover { color: var(--text); }
|
||||||
|
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
|
||||||
|
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
|
||||||
|
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
|
@ -25,6 +25,7 @@ concat_files \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
|
"../shared/seltable.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
"../form/css/form.css" \
|
"../form/css/form.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -46,6 +47,7 @@ concat_files \
|
||||||
"../shared/profile-menu.js" \
|
"../shared/profile-menu.js" \
|
||||||
"../shared/cap.js" \
|
"../shared/cap.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
|
"../shared/seltable.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
|
|
@ -61,6 +63,7 @@ concat_files \
|
||||||
"js/export.js" \
|
"js/export.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/api-actions.js" \
|
"js/api-actions.js" \
|
||||||
|
"js/mdl-from-archive.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
"../form/js/app.js" \
|
"../form/js/app.js" \
|
||||||
"../form/js/context.js" \
|
"../form/js/context.js" \
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Add from archive" — shown only on the project MDL rollup (own gating).
|
||||||
|
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
|
||||||
|
app.modules.mdlFromArchive.setup(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
184
tables/js/mdl-from-archive.js
Normal file
184
tables/js/mdl-from-archive.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
|
||||||
|
//
|
||||||
|
// The MDL owns the workflow of registering deliverables; this is the catch-up
|
||||||
|
// path. On the project rollup (<project>/mdl/), walk the project archive into a
|
||||||
|
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
|
||||||
|
// one deliverable per tracking number, and PUT a deliverable .yaml into each
|
||||||
|
// originator's archive/<originator>/mdl/. The body's identity fields are split
|
||||||
|
// from the tracking number positionally per the project's own table columns
|
||||||
|
// (originator is folder-pinned, so omitted); the server composes/validates the
|
||||||
|
// filename. Server-only.
|
||||||
|
(function (app) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
||||||
|
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||||
|
function ctxObj() { return (app && app.context) || {}; }
|
||||||
|
|
||||||
|
// The tracking-number identity fields, in order, from the table columns:
|
||||||
|
// everything between `originator` and `title` (e.g. phase, project, area,
|
||||||
|
// discipline, type, sequence, suffix). originator is folder-pinned.
|
||||||
|
function identityFields() {
|
||||||
|
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
|
||||||
|
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
|
||||||
|
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
|
||||||
|
}
|
||||||
|
// tracking → { tracking, originator, body{identity fields + title} }, or null
|
||||||
|
// if it can't supply the originator + at least one identity segment.
|
||||||
|
function deliverableFromFile(f, idFields) {
|
||||||
|
var segs = String(f.tracking || '').split('-');
|
||||||
|
if (segs.length < 2) return null;
|
||||||
|
var rest = segs.slice(1), body = {};
|
||||||
|
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
|
||||||
|
if (!Object.keys(body).length) return null;
|
||||||
|
body.title = f.title || '';
|
||||||
|
return { tracking: f.tracking, originator: segs[0], body: body };
|
||||||
|
}
|
||||||
|
function dedupe(files, idFields) {
|
||||||
|
var seen = Object.create(null), out = [];
|
||||||
|
(files || []).forEach(function (f) {
|
||||||
|
if (seen[f.tracking]) return;
|
||||||
|
var d = deliverableFromFile(f, idFields);
|
||||||
|
if (d) { seen[f.tracking] = true; out.push(d); }
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkArchive(rootHandle) {
|
||||||
|
var out = [];
|
||||||
|
async function walk(dirH, parts) {
|
||||||
|
for await (var entry of dirH.values()) {
|
||||||
|
var nm = String(entry.name || '').replace(/\/$/, '');
|
||||||
|
if (entry.kind === 'directory') {
|
||||||
|
var c = nm.charAt(0);
|
||||||
|
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
||||||
|
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
|
||||||
|
} else {
|
||||||
|
var p = window.zddc.parseFilename(nm);
|
||||||
|
if (p && p.valid && p.trackingNumber) {
|
||||||
|
out.push({
|
||||||
|
id: parts.concat(nm).join('/'),
|
||||||
|
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
||||||
|
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(rootHandle, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
async function instantiateOne(archiveRoot, d) {
|
||||||
|
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
||||||
|
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
||||||
|
var fname = d.tracking + '.yaml';
|
||||||
|
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
||||||
|
var fh = await dir.getFileHandle(fname, { create: true });
|
||||||
|
var w = await fh.createWritable();
|
||||||
|
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
|
||||||
|
await w.close();
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI ───────────────────────────────────────────────────────────────────
|
||||||
|
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
||||||
|
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
||||||
|
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
||||||
|
|
||||||
|
function archiveBaseUrl() {
|
||||||
|
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
|
||||||
|
return location.origin + proj + 'archive/';
|
||||||
|
}
|
||||||
|
async function open() {
|
||||||
|
var src = window.zddc && window.zddc.source;
|
||||||
|
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
|
||||||
|
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
|
||||||
|
}
|
||||||
|
buildOverlay();
|
||||||
|
try {
|
||||||
|
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
|
||||||
|
setStatus('Scanning archive…');
|
||||||
|
files = await walkArchive(archiveRoot);
|
||||||
|
table.renderBody();
|
||||||
|
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
||||||
|
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
function buildOverlay() {
|
||||||
|
close();
|
||||||
|
overlay = el('div', 'mdlarch-overlay');
|
||||||
|
var box = el('div', 'mdlarch-overlay__box');
|
||||||
|
var head = el('div', 'mdlarch-overlay__head');
|
||||||
|
head.appendChild(el('h2', null, 'Add deliverables from archive'));
|
||||||
|
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
||||||
|
head.appendChild(x); box.appendChild(head);
|
||||||
|
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
|
||||||
|
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
|
||||||
|
var foot = el('div', 'mdlarch-overlay__foot');
|
||||||
|
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
||||||
|
create.addEventListener('click', function () { runCreate(create); });
|
||||||
|
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
||||||
|
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
|
||||||
|
overlay.appendChild(box); document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
table = window.app.modules.seltable.create({
|
||||||
|
container: host,
|
||||||
|
extraTitle: '',
|
||||||
|
rows: function () { return files; },
|
||||||
|
rowId: function (r) { return r.id; },
|
||||||
|
columns: [
|
||||||
|
{ key: 'party', title: 'Party' },
|
||||||
|
{ key: 'slot', title: 'Slot' },
|
||||||
|
{ key: 'transmittal', title: 'Transmittal' },
|
||||||
|
{ key: 'tracking', title: 'Tracking number' },
|
||||||
|
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
||||||
|
{ key: 'title', title: 'Title' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
table.render();
|
||||||
|
}
|
||||||
|
async function runCreate(btn) {
|
||||||
|
if (!table) return;
|
||||||
|
var sel = table.getSelection();
|
||||||
|
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
||||||
|
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
|
||||||
|
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
|
||||||
|
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
|
||||||
|
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
var s = { created: 0, skipped: 0, errors: 0 };
|
||||||
|
for (var i = 0; i < deliverables.length; i++) {
|
||||||
|
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
|
||||||
|
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
||||||
|
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
|
||||||
|
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the toolbar button only on the project MDL rollup (addable:false +
|
||||||
|
// an mdl path), over http, gated on create permission. Called from main.js
|
||||||
|
// init once the context is known.
|
||||||
|
function setup(ctx) {
|
||||||
|
var btn = document.getElementById('table-add-from-archive');
|
||||||
|
if (!btn) return;
|
||||||
|
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
|
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
|
||||||
|
if (!(onHttp && isMdlRollup)) return;
|
||||||
|
btn.hidden = false;
|
||||||
|
btn.addEventListener('click', open);
|
||||||
|
if (window.zddc && window.zddc.cap) {
|
||||||
|
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
|
||||||
|
var verbs = (view && view.path_verbs) || '';
|
||||||
|
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules.mdlFromArchive = {
|
||||||
|
setup: setup, open: open,
|
||||||
|
// test seams
|
||||||
|
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
|
||||||
|
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
|
||||||
|
};
|
||||||
|
})(window.tablesApp);
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
<div class="table-toolbar__right">
|
<div class="table-toolbar__right">
|
||||||
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
||||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||||
|
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)">+ From archive</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>
|
||||||
|
|
|
||||||
194
tests/tables-mdl.spec.js
Normal file
194
tests/tables-mdl.spec.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// "Add from archive" for the tables tool's project MDL rollup. The page is
|
||||||
|
// loaded offline (file://) with an injected #table-context whose columns drive
|
||||||
|
// how a tracking number splits into deliverable fields. The walk / dedupe /
|
||||||
|
// instantiate logic is exercised against in-page mock FS-Access handles — no
|
||||||
|
// server needed.
|
||||||
|
|
||||||
|
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
||||||
|
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
||||||
|
|
||||||
|
// originator … identity fields … title (originator is folder-pinned → omitted
|
||||||
|
// from the body; everything between originator and title is the tracking split).
|
||||||
|
const MDL_COLUMNS = [
|
||||||
|
{ field: 'originator', title: 'Orig' },
|
||||||
|
{ field: 'phase', title: 'Phase' },
|
||||||
|
{ field: 'project', title: 'Project' },
|
||||||
|
{ field: 'area', title: 'Area' },
|
||||||
|
{ field: 'discipline', title: 'Disc' },
|
||||||
|
{ field: 'type', title: 'Type' },
|
||||||
|
{ field: 'sequence', title: 'Seq' },
|
||||||
|
{ field: 'suffix', title: 'Suffix' },
|
||||||
|
{ field: 'title', title: 'Deliverable' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadRollup(page) {
|
||||||
|
const ctx = { title: 'MDL', columns: MDL_COLUMNS, rows: [], addable: false };
|
||||||
|
const ctxJson = JSON.stringify(ctx).replace(/<\//g, '<\\/');
|
||||||
|
const patched = HTML_RAW.replace(
|
||||||
|
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
|
||||||
|
`<script id="table-context" type="application/json">${ctxJson}</script>`,
|
||||||
|
);
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-mdl-'));
|
||||||
|
const tmpPath = path.join(tmpDir, 'tables.html');
|
||||||
|
fs.writeFileSync(tmpPath, patched);
|
||||||
|
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.tablesApp && window.tablesApp.modules && window.tablesApp.modules.mdlFromArchive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('tables/ — Add deliverables from archive', () => {
|
||||||
|
test('identityFields() = columns between originator and title', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const fields = await page.evaluate(() => window.tablesApp.modules.mdlFromArchive.identityFields());
|
||||||
|
expect(fields).toEqual(['phase', 'project', 'area', 'discipline', 'type', 'sequence', 'suffix']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deliverableFromFile splits the tracking number, omits originator, keeps title', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const d = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
return m.deliverableFromFile(
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001-X', title: 'Foundation Plan' },
|
||||||
|
m.identityFields(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(d.originator).toBe('ACME');
|
||||||
|
expect(d.tracking).toBe('ACME-DD-PRJ-A1-CIV-DWG-001-X');
|
||||||
|
expect(d.body).toEqual({
|
||||||
|
phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV',
|
||||||
|
type: 'DWG', sequence: '001', suffix: 'X', title: 'Foundation Plan',
|
||||||
|
});
|
||||||
|
// originator must NOT be in the body (server pins it from the folder).
|
||||||
|
expect(d.body.originator).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shorter tracking number leaves trailing identity fields unset', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const d = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
// no suffix segment
|
||||||
|
return m.deliverableFromFile({ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: '' }, m.identityFields());
|
||||||
|
});
|
||||||
|
expect(d.body.sequence).toBe('001');
|
||||||
|
expect('suffix' in d.body).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupe collapses duplicate tracking numbers, dropping unsplittable rows', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const out = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
return m.dedupe([
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a' },
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a-dup' },
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', title: 'b' },
|
||||||
|
{ tracking: 'NOPE', title: 'too short' },
|
||||||
|
], m.identityFields());
|
||||||
|
});
|
||||||
|
expect(out.map(d => d.tracking)).toEqual([
|
||||||
|
'ACME-DD-PRJ-A1-CIV-DWG-001', 'ACME-DD-PRJ-A1-CIV-DWG-002',
|
||||||
|
]);
|
||||||
|
expect(out[0].body.title).toBe('a'); // first wins
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walkArchive collects valid document files, skipping mdl/rsk/dot/underscore dirs', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const files = await page.evaluate(async () => {
|
||||||
|
// Mock FS-Access directory handles.
|
||||||
|
function dir(name, entries) {
|
||||||
|
return {
|
||||||
|
name, kind: 'directory', _entries: entries,
|
||||||
|
async *values() { for (const e of entries) yield e; },
|
||||||
|
async getDirectoryHandle(n) {
|
||||||
|
const e = entries.find(x => x.name === n && x.kind === 'directory');
|
||||||
|
if (!e) throw new DOMException('not found', 'NotFoundError');
|
||||||
|
return e;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const file = name => ({ name, kind: 'file' });
|
||||||
|
const root = dir('archive', [
|
||||||
|
dir('Acme', [
|
||||||
|
dir('issued', [
|
||||||
|
dir('2026-05-01_ACME-DD-PRJ-A1-CIV-DWG-001 (IFR) - Plan', [
|
||||||
|
file('ACME-DD-PRJ-A1-CIV-DWG-001_B (IFR) - Foundation Plan.pdf'),
|
||||||
|
file('not-a-zddc-file.txt'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
dir('mdl', [ file('ACME-DD-PRJ-A1-CIV-DWG-001.yaml') ]), // skipped
|
||||||
|
dir('rsk', [ file('whatever_A (IFA) - x.pdf') ]), // skipped
|
||||||
|
]),
|
||||||
|
dir('_system', [ file('ACME-DD-PRJ-A1-CIV-DWG-999_A (IFA) - hidden.pdf') ]), // skipped
|
||||||
|
]);
|
||||||
|
const out = await window.tablesApp.modules.mdlFromArchive.walkArchive(root);
|
||||||
|
return out.map(f => ({ tracking: f.tracking, party: f.party, slot: f.slot, rev: f.revision, title: f.title }));
|
||||||
|
});
|
||||||
|
expect(files).toEqual([
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', party: 'Acme', slot: 'issued', rev: 'B', title: 'Foundation Plan' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('instantiateOne writes a yaml on create, skips when it already exists', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const writes = [];
|
||||||
|
function fileHandle(name, exists) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
async createWritable() {
|
||||||
|
return {
|
||||||
|
async write(blob) { writes.push({ name, text: await blob.text() }); },
|
||||||
|
async close() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
_exists: exists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function mdlDir() {
|
||||||
|
const present = {}; // tracking.yaml already there
|
||||||
|
present['ACME-DD-PRJ-A1-CIV-DWG-002.yaml'] = true;
|
||||||
|
return {
|
||||||
|
async getFileHandle(n, opts) {
|
||||||
|
if (opts && opts.create) return fileHandle(n, false);
|
||||||
|
if (present[n]) return fileHandle(n, true);
|
||||||
|
throw new DOMException('nf', 'NotFoundError');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function originatorDir() {
|
||||||
|
return { async getDirectoryHandle() { return mdlDir(); } };
|
||||||
|
}
|
||||||
|
const archiveRoot = { async getDirectoryHandle() { return originatorDir(); } };
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
const created = await m.instantiateOne(archiveRoot, {
|
||||||
|
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', originator: 'ACME',
|
||||||
|
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '001', title: 'Plan' },
|
||||||
|
});
|
||||||
|
const skipped = await m.instantiateOne(archiveRoot, {
|
||||||
|
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', originator: 'ACME',
|
||||||
|
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '002', title: 'Plan2' },
|
||||||
|
});
|
||||||
|
return { created, skipped, writes };
|
||||||
|
});
|
||||||
|
expect(result.created).toBe('created');
|
||||||
|
expect(result.skipped).toBe('skipped');
|
||||||
|
expect(result.writes.length).toBe(1);
|
||||||
|
expect(result.writes[0].name).toBe('ACME-DD-PRJ-A1-CIV-DWG-001.yaml');
|
||||||
|
expect(result.writes[0].text).toContain('title: Plan');
|
||||||
|
expect(result.writes[0].text).toContain('discipline: CIV');
|
||||||
|
// originator must not be serialized into the body
|
||||||
|
expect(result.writes[0].text).not.toContain('originator:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the "From archive" button stays hidden when not on an /mdl/ rollup path', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
// file:// path is not /mdl/, so setup() must not reveal the button.
|
||||||
|
const hidden = await page.evaluate(() => document.getElementById('table-add-from-archive').hidden);
|
||||||
|
expect(hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue