Three triggers for flushing pending edits:
- Save button in the toolbar — shown only when ≥1 row is dirty,
label reads "Save (N unsaved)". Disappears after a clean settle.
- Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats
the browser's "Save Page As" default.
- focusout of #table-root with a relatedTarget outside the grid —
catches "edit cell, click a header link, expect it to save".
The row-blur trigger stays — moving between rows still flushes. The
new triggers fill the gap when the user edits one row and then leaves
the grid entirely without first navigating to another row.
Dirty marker gets a 4px (was 3px) left swatch AND a faint blue
background tint on the row, so "unsaved" reads as a row state rather
than a small marker on the edge.
editor.setDraft / clearDraftField notify save.onDraftsChanged,
which refreshes the Save button + reapplies the dirty class.
saveRow on 200/201/202 also refreshes the button so it disappears
the moment its row settles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
553 lines
23 KiB
JavaScript
553 lines
23 KiB
JavaScript
// 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.
|
||
return Object.assign({}, data || {}, drafts || {});
|
||
}
|
||
|
||
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, '');
|
||
row.data = 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 };
|
||
}
|
||
|
||
// 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 <id>.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;
|
||
row.data = merged;
|
||
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 };
|
||
}
|
||
|
||
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);
|