// save.js — Phase 3 of editable-cell mode. // // Row-level batch save on row-blur. While the user is editing cells // inside a row, draft values accumulate in app.state.drafts. When the // editor's selection moves to a different row (or focus leaves the // grid entirely), this module fires one PUT for the row that lost // focus, with full merged data + If-Match for the row's tracked ETag. // // Three response paths: // // - 200 / 201 / 202: success or queued-offline (cache outbox). // Drafts clear, row.data merges, new ETag captured. Row's // "dirty" indicator drops. // // - 412 Precondition Failed: someone else changed this row since // we read it. Drafts STAY — never silently discard the user's // typing. Row gets a "stale" badge with [Use mine] / [Reload] // in the page status bar. "Use mine" re-GETs the row to pick up // the new ETag and server data, replays drafts on top, re-PUTs // (this is the client-side field-level LWW trick from the // architecture report — fields the user didn't touch get the // server's new values automatically). "Reload" drops drafts and // refreshes from server. // // - 422 Unprocessable Entity: server-side schema validation failed. // Body is {errors: [{path, message}, ...]}. Each path → field, // marked with a red corner on the cell. Drafts stay so the user // can correct in place. // // - Other (4xx / 5xx / network): row marked errored with the // status code; drafts stay. // // Outbox transparency: when running through a downstream client, the // PUT is intercepted by the cache layer; on local network failure // it's queued and the response is 202 Accepted with X-ZDDC-Cache: // queued. We treat 202 as success-ish — drafts clear, indicator // shows a small "queued" badge so the user knows the write hasn't // reached upstream yet. (function (app) { 'use strict'; function modules() { return app.modules.editor; } function findRowById(rowId) { const all = (app.state && app.state.rows) || []; for (let i = 0; i < all.length; i++) { if (modules().rowKey(all[i]) === rowId) return all[i]; } return null; } function mergeRow(data, drafts) { // Shallow merge: drafts are field-level overrides on the row's // top-level data object. Phase 2's complex-type cells punt to // form-mode and never produce drafts here, so drafts only // contain primitive / string-array values that are safe to // overwrite the corresponding top-level field. // // $-prefixed keys are system-synthesised on read (e.g. `$party`, // injected by the server on mdl/rsk rows or derived from the // party subdir in the aggregate view). They are not part of the // row's stored YAML and would be rejected by the schema's // additionalProperties rule. Strip them before sending the write. const merged = Object.assign({}, data || {}, drafts || {}); for (const k of Object.keys(merged)) { if (k.charAt(0) === '$') delete merged[k]; } return merged; } function rowFromState(rowId) { return { row: findRowById(rowId), drafts: (app.state.drafts && app.state.drafts[rowId]) || null, }; } // --- Visual state markers ---------------------------------------- function setRowState(rowId, stateName) { // Apply a CSS state class to the row matching rowId. States: // "" / null — no marker // "dirty" — has uncommitted drafts // "saving" — PUT in flight // "stale" — server returned 412 // "errored" — server returned 4xx/5xx other than 412/422 // "queued" — write went into the outbox // "invalid" — server returned 422 const tbody = document.querySelector('#table-root tbody'); if (!tbody) return; const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]'); if (!tr) return; const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid']; for (let i = 0; i < stateClasses.length; i++) { tr.classList.remove('zddc-table__row--' + stateClasses[i]); } if (stateName) tr.classList.add('zddc-table__row--' + stateName); } function markCellInvalid(rowId, field, message) { const tbody = document.querySelector('#table-root tbody'); if (!tbody) return; const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]'); if (!tr) return; // Walk the column list to find the field's column index; // data-col-idx is the numeric position rendered into each td. const cols = (app.context && app.context.columns) || []; let idx = -1; for (let i = 0; i < cols.length; i++) { if (cols[i].field === field) { idx = i; break; } } if (idx < 0) return; const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]'); if (!target) return; target.classList.add('zddc-table__cell--invalid'); if (message) target.setAttribute('title', message); } function clearCellInvalid(rowId) { const tbody = document.querySelector('#table-root tbody'); if (!tbody) return; const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]'); if (!tr) return; const invalids = tr.querySelectorAll('.zddc-table__cell--invalid'); for (let i = 0; i < invalids.length; i++) { invalids[i].classList.remove('zddc-table__cell--invalid'); invalids[i].removeAttribute('title'); } } function cssEscape(s) { // CSS.escape if available; otherwise a defensive escape for // the characters that appear in URL paths used as data-row-id // values. Browsers everywhere modern enough to support the // FS Access API have CSS.escape, so this is mostly defensive. if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s); return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; }); } // --- Status bar (stale-row prompt) -------------------------------- function showStatusPrompt(rowId, message, actions) { // Renders into #table-status (hidden by default per template). // actions = [{label, onClick}, ...] const el = document.getElementById('table-status'); if (!el) return; el.textContent = ''; el.classList.add('table-status--prompt'); const span = document.createElement('span'); span.textContent = message; el.appendChild(span); for (let i = 0; i < (actions || []).length; i++) { const a = actions[i]; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-secondary btn-sm'; btn.textContent = a.label; btn.addEventListener('click', a.onClick); el.appendChild(btn); } const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'btn btn-secondary btn-sm'; dismiss.textContent = '×'; dismiss.title = 'Dismiss'; dismiss.addEventListener('click', clearStatus); el.appendChild(dismiss); el.hidden = false; el.setAttribute('data-row-id', rowId); } function clearStatus() { const el = document.getElementById('table-status'); if (!el) return; el.textContent = ''; el.hidden = true; el.removeAttribute('data-row-id'); el.classList.remove('table-status--prompt'); } // --- The save itself --------------------------------------------- async function saveRow(rowId, opts) { opts = opts || {}; const { row, drafts } = rowFromState(rowId); if (!row) return { status: 'noop' }; const hasDrafts = drafts && Object.keys(drafts).length > 0; // New (unsaved) rows: if the user added a row and then moved on // without typing anything, drop the empty placeholder rather // than POST an empty body that fails schema validation. if (row.isNew && !hasDrafts) { const addRow = app.modules.addRow; if (addRow && typeof addRow.discardEmpty === 'function') { addRow.discardEmpty(rowId); } return { status: 'discarded-empty' }; } if (!hasDrafts) return { status: 'noop' }; if (row.isNew) { return createRow(rowId, row, drafts, opts); } if (!row.yamlUrl) { // file:// mode or rows from inline-context test fixtures // don't have a URL to PUT to — bail silently. return { status: 'no-url' }; } if (row.editable === false) { // Row is read-only per the server. Don't even try. return { status: 'readonly' }; } setRowState(rowId, 'saving'); const merged = mergeRow(row.data, drafts); const yamlBody = window.jsyaml.dump(merged); const headers = { 'Content-Type': 'application/yaml; charset=utf-8' }; if (row.etag) headers['If-Match'] = '"' + row.etag + '"'; const fetchOpts = { method: 'PUT', body: yamlBody, headers: headers, credentials: 'same-origin', }; // The unload path passes keepalive:true so the PUT outlives the // page navigation. Subject to the spec's 64 KB body cap — large // rows may fail in that path; normal saves are unaffected. if (opts.keepalive) fetchOpts.keepalive = true; let resp; try { resp = await fetch(row.yamlUrl, fetchOpts); } catch (err) { // Network failure — outbox-fronted client should still // resolve with 202; reaching here means a hard client-side // network error. Mark errored, drafts stay. console.error('[tables] save network error', err); setRowState(rowId, 'errored'); return { status: 'network-error', error: err }; } if (resp.status === 200 || resp.status === 201) { // Success: clear drafts + invalid marks, capture new ETag. const newEtag = resp.headers.get('ETag'); if (newEtag) row.etag = newEtag.replace(/"/g, ''); // For record-typed writes the server echoes the stamped // YAML (with server-managed audit fields) back as the // response body — parse it and overwrite row.data so the // table sees the same bytes that just landed on disk. // Falls back to the local merge when the server didn't // echo a body (non-record write or older server). let serverData = null; const ct = (resp.headers.get('Content-Type') || '').toLowerCase(); if (ct.includes('yaml') && window.jsyaml) { try { const text = await resp.text(); if (text && text.trim()) serverData = window.jsyaml.load(text); } catch (e) { console.warn('[tables] server response YAML parse failed; using local merge', e); } } row.data = serverData || merged; delete app.state.drafts[rowId]; clearCellInvalid(rowId); setRowState(rowId, ''); // If a status prompt was up for this row, drop it. const sb = document.getElementById('table-status'); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); updateSaveButton(); return { status: 'ok' }; } if (resp.status === 202) { // Outbox queued. Drafts clear (they're persisted in the // outbox; the server will replay them on reconnect), but // the row stays marked queued so the user knows. row.data = merged; delete app.state.drafts[rowId]; setRowState(rowId, 'queued'); updateSaveButton(); return { status: 'queued' }; } if (resp.status === 412) { // Precondition Failed — someone else changed the row. // Drafts STAY. Surface the prompt. setRowState(rowId, 'stale'); showStatusPrompt( rowId, 'This row was changed by someone else. ', [ { label: 'Use mine', onClick: () => useMine(rowId) }, { label: 'Reload', onClick: () => reload(rowId) }, ] ); return { status: 'conflict' }; } if (resp.status === 422) { // Validation errors. Body shape matches the form system's // 422 response: {errors: [{path: "/field", message}, ...]}. let body = {}; try { body = await resp.json(); } catch (_) { /* ignore */ } clearCellInvalid(rowId); const errs = body.errors || []; for (let i = 0; i < errs.length; i++) { const e = errs[i]; const field = String(e.path || '').replace(/^\//, '').split('/')[0]; if (field) markCellInvalid(rowId, field, e.message || 'invalid'); } setRowState(rowId, 'invalid'); return { status: 'invalid', errors: errs }; } if (resp.status === 403) { setRowState(rowId, 'errored'); if (window.zddc && window.zddc.cap) { window.zddc.cap.handleForbidden(resp, { context: 'Save row', path: location.pathname }); } return { status: 'forbidden' }; } // Other status — generic error. console.warn('[tables] save returned', resp.status); setRowState(rowId, 'errored'); return { status: 'http-error', code: resp.status }; } // createRow handles the POST path for an isNew row. Body is YAML of // the row's draft data (no row.data yet — it's a fresh row). Success // is 201 + Location pointing at the new .yaml; we swap the // synthetic url/yamlUrl for the real ones and clear isNew so the // row behaves like any other from this point on. async function createRow(rowId, row, drafts, opts) { const addRow = app.modules.addRow; if (!addRow || typeof addRow.formCreateUrl !== 'function') { setRowState(rowId, 'errored'); return { status: 'no-create-url' }; } const createUrl = addRow.formCreateUrl(); const merged = mergeRow(row.data, drafts); const yamlBody = window.jsyaml.dump(merged); const headers = { 'Content-Type': 'application/yaml; charset=utf-8' }; const fetchOpts = { method: 'POST', body: yamlBody, headers: headers, credentials: 'same-origin', }; if (opts && opts.keepalive) fetchOpts.keepalive = true; setRowState(rowId, 'saving'); let resp; try { resp = await fetch(createUrl, fetchOpts); } catch (err) { console.error('[tables] createRow network error', err); setRowState(rowId, 'errored'); return { status: 'network-error', error: err }; } if (resp.status === 201) { // Server wrote the row. Body is {location, filename}; we // also accept the Location header if the body isn't JSON. let body = {}; try { body = await resp.json(); } catch (_) { /* ignore */ } const location = body.location || resp.headers.get('Location') || ''; const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, ''); row.yamlUrl = location; row.url = location ? location + '.html' : row.url; // Re-fetch the just-written row so server-derived fields // surface immediately: folder-bound originator, the composed // tracking number's components, and audit stamps. The local // `merged` lacks these (e.g. originator is read-only and // never typed). Fall back to merged if the GET fails. row.data = merged; if (location) { try { const back = await fetch(location, { credentials: 'same-origin' }); if (back.ok) { const text = await back.text(); if (text && text.trim() && window.jsyaml) { row.data = window.jsyaml.load(text) || merged; } const fetchedEtag = (back.headers.get('ETag') || '').replace(/"/g, ''); if (fetchedEtag) row.etag = fetchedEtag; } } catch (e) { console.warn('[tables] post-create re-fetch failed; using local merge', e); } } if (!row.etag) row.etag = newEtag || null; row.isNew = false; // Move the drafts entry (was keyed on the synthetic id) to // the new url, then clear it (data has the merged values). delete app.state.drafts[rowId]; clearCellInvalid(rowId); setRowState(rowId, ''); const sb = document.getElementById('table-status'); if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus(); // Re-paint so the row picks up its new data-row-id and any // server-supplied default fields surface. if (typeof app.repaint === 'function') app.repaint(); return { status: 'ok' }; } if (resp.status === 422) { let body = {}; try { body = await resp.json(); } catch (_) { /* ignore */ } clearCellInvalid(rowId); const errs = body.errors || []; for (let i = 0; i < errs.length; i++) { const e = errs[i]; const field = String(e.path || '').replace(/^\//, '').split('/')[0]; if (field) markCellInvalid(rowId, field, e.message || 'invalid'); } setRowState(rowId, 'invalid'); return { status: 'invalid', errors: errs }; } if (resp.status === 403) { setRowState(rowId, 'errored'); if (window.zddc && window.zddc.cap) { window.zddc.cap.handleForbidden(resp, { context: 'Add row', path: location.pathname }); } return { status: 'forbidden' }; } if (resp.status === 409) { // The composed tracking number collides with an existing // row (the server rejects duplicates). Surface it on the // sequence cell — the usual disambiguator — rather than the // generic errored state, so the user knows to bump a // component instead of retrying the same values. let msg = 'Duplicate tracking number — change a component (e.g. sequence).'; try { const t = await resp.text(); if (t && t.trim()) msg = t.trim(); } catch (_) { /* ignore */ } clearCellInvalid(rowId); markCellInvalid(rowId, 'sequence', msg); setRowState(rowId, 'invalid'); return { status: 'duplicate', message: msg }; } console.warn('[tables] createRow returned', resp.status); setRowState(rowId, 'errored'); return { status: 'http-error', code: resp.status }; } async function useMine(rowId) { const { row, drafts } = rowFromState(rowId); if (!row || !drafts) return; // Re-GET the row to learn the latest server state + ETag. try { const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' }); if (!resp.ok) { console.warn('[tables] reload on conflict failed', resp.status); return; } const text = await resp.text(); const fresh = window.jsyaml.load(text) || {}; row.data = fresh; const newEtag = resp.headers.get('ETag'); row.etag = newEtag ? newEtag.replace(/"/g, '') : null; } catch (err) { console.error('[tables] reload on conflict error', err); return; } // Drafts preserved — replay against the new base. return saveRow(rowId); } async function reload(rowId) { const row = findRowById(rowId); if (!row) return; try { const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' }); if (!resp.ok) return; const text = await resp.text(); row.data = window.jsyaml.load(text) || {}; const newEtag = resp.headers.get('ETag'); row.etag = newEtag ? newEtag.replace(/"/g, '') : null; } catch (_) { return; } delete app.state.drafts[rowId]; clearCellInvalid(rowId); setRowState(rowId, ''); clearStatus(); // Trigger a re-paint via the public app callback if one exists. if (typeof app.repaint === 'function') app.repaint(); } // --- Trigger: row-blur ------------------------------------------ let _previousSelectedRowId = null; function trackSelectionChange(prevRowId, nextRowId) { // Fires when the editor's selection changes rows. If prevRow // had drafts, save it now. nextRow can be null (focus left // the grid) — also a save trigger. if (prevRowId && prevRowId !== nextRowId) { const drafts = app.state.drafts && app.state.drafts[prevRowId]; if (drafts && Object.keys(drafts).length > 0) { // Fire and forget. The user has moved on; we don't // want to block their flow waiting for the server. saveRow(prevRowId).catch(err => { console.error('[tables] saveRow rejection', err); }); } } } function onSelectionChanged(selected) { const prevRowId = _previousSelectedRowId; const nextRowId = selected ? rowIdAtIndex(selected.row) : null; if (prevRowId !== nextRowId) { trackSelectionChange(prevRowId, nextRowId); _previousSelectedRowId = nextRowId; } // Mark dirty rows visually whenever selection settles. markAllDirtyRows(); } function rowIdAtIndex(visibleRowIdx) { const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx]; return tr ? tr.getAttribute('data-row-id') : null; } function markAllDirtyRows() { // After a re-paint or selection change, re-apply dirty state // to any row that has drafts (CSS classes don't survive // tbody.innerHTML='' in renderBody). const drafts = app.state.drafts || {}; const tbody = document.querySelector('#table-root tbody'); if (!tbody) return; const trs = tbody.querySelectorAll('tr'); for (let i = 0; i < trs.length; i++) { const tr = trs[i]; const rowId = tr.getAttribute('data-row-id'); if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) { if (!tr.classList.contains('zddc-table__row--saving') && !tr.classList.contains('zddc-table__row--stale') && !tr.classList.contains('zddc-table__row--invalid') && !tr.classList.contains('zddc-table__row--errored') && !tr.classList.contains('zddc-table__row--queued')) { tr.classList.add('zddc-table__row--dirty'); } } } } function flushAllDrafts() { // Page-unload safety net. Best-effort: any row with drafts // gets one final save attempt. fetch() is async, the page may // already be navigating; we just kick the requests off. const drafts = app.state.drafts || {}; const ids = Object.keys(drafts); for (let i = 0; i < ids.length; i++) { saveRow(ids[i], { keepalive: true }).catch(() => {}); } } // flushAll fires saves for every dirty row and returns when they // all settle. Used by the explicit Save button and the auto-save // when focus leaves the grid. Unlike flushAllDrafts, this is NOT // keepalive — the page isn't going anywhere, so we wait for real // responses and surface errors normally. async function flushAll() { const drafts = app.state.drafts || {}; const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0); if (ids.length === 0) return { status: 'noop' }; const results = await Promise.allSettled(ids.map(id => saveRow(id))); const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length; return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok }; } // Count rows that have at least one unsaved field. function dirtyCount() { const drafts = app.state.drafts || {}; let n = 0; for (const id in drafts) { if (drafts[id] && Object.keys(drafts[id]).length > 0) n++; } return n; } // Update the toolbar Save button visibility + label from current // draft state. Called from editor.js whenever drafts mutate; also // safe to call anytime (e.g. after a paint). function updateSaveButton() { const btn = document.getElementById('table-save'); if (!btn) return; const n = dirtyCount(); if (n === 0) { btn.hidden = true; btn.textContent = 'Save'; return; } btn.hidden = false; btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)'; } function onDraftsChanged() { updateSaveButton(); markAllDirtyRows(); } // Window unload handler — call any in-flight drafts so the user // doesn't lose typing on tab-close. The PUT uses keepalive:true so // it survives navigation; that comes with a 64 KB body cap. window.addEventListener('beforeunload', function (_ev) { flushAllDrafts(); }); app.modules.save = { saveRow: saveRow, useMine: useMine, reload: reload, onSelectionChanged: onSelectionChanged, onDraftsChanged: onDraftsChanged, markAllDirtyRows: markAllDirtyRows, updateSaveButton: updateSaveButton, flushAll: flushAll, dirtyCount: dirtyCount, flushAllDrafts: flushAllDrafts, }; })(window.tablesApp);