diff --git a/playwright.config.js b/playwright.config.js index c8f3330..4fe492e 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -95,6 +95,10 @@ export default defineConfig({ name: 'tables', testMatch: 'tables.spec.js', }, + { + name: 'tables-mdl', + testMatch: 'tables-mdl.spec.js', + }, { name: 'zddc-filter', testMatch: 'zddc-filter.spec.js', diff --git a/shared/seltable.css b/shared/seltable.css new file mode 100644 index 0000000..93d3b7c --- /dev/null +++ b/shared/seltable.css @@ -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; } diff --git a/tables/build.sh b/tables/build.sh index 03181d1..a6e0570 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -25,6 +25,7 @@ concat_files \ "../shared/profile-menu.css" \ "../shared/logo.css" \ "../shared/context-menu.css" \ + "../shared/seltable.css" \ "css/table.css" \ "../form/css/form.css" \ > "$css_temp" @@ -46,6 +47,7 @@ concat_files \ "../shared/profile-menu.js" \ "../shared/cap.js" \ "../shared/context-menu.js" \ + "../shared/seltable.js" \ "js/mode.js" \ "js/app.js" \ "js/context.js" \ @@ -61,6 +63,7 @@ concat_files \ "js/export.js" \ "js/render.js" \ "js/api-actions.js" \ + "js/mdl-from-archive.js" \ "js/main.js" \ "../form/js/app.js" \ "../form/js/context.js" \ diff --git a/tables/js/main.js b/tables/js/main.js index a1b687e..ee7ba37 100644 --- a/tables/js/main.js +++ b/tables/js/main.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 allRows = Array.isArray(ctx.rows) ? ctx.rows : []; diff --git a/tables/js/mdl-from-archive.js b/tables/js/mdl-from-archive.js new file mode 100644 index 0000000..3a3ce4a --- /dev/null +++ b/tables/js/mdl-from-archive.js @@ -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 (/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//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\/.*$/, '/'); // / + 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//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); diff --git a/tables/template.html b/tables/template.html index 7d8a8d5..11b83a1 100644 --- a/tables/template.html +++ b/tables/template.html @@ -43,6 +43,7 @@
+
diff --git a/tests/tables-mdl.spec.js b/tests/tables-mdl.spec.js new file mode 100644 index 0000000..ada5e2f --- /dev/null +++ b/tests/tables-mdl.spec.js @@ -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( + /`, + ); + 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); + }); +});