T8.6 - D6: the material list uploads the way the location list does
CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.
THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.
The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).
Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.
Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.
Items: D6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
210
html/wp-list-import.js
Normal file
210
html/wp-list-import.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6).
|
||||
|
||||
One implementation of paste-or-file → server-side import with dry-run →
|
||||
a report that names every rejected row with its SOURCE line number → an
|
||||
editable list whose entries deactivate rather than delete. The location list
|
||||
(CR-005) and the material list (D6) are both instances of this; building the
|
||||
material path "the same way and against the same component, not beside it"
|
||||
is the T8.6 instruction, and extracting the component is what makes that
|
||||
literally true rather than a copy with the names changed.
|
||||
|
||||
The page supplies what differs: the API base, the sample, how a row renders,
|
||||
and what the add-row collects. Everything generic — the file reader feeding
|
||||
the paste box (one parser, on the server), the dry-run wiring, the report
|
||||
roles (a report that lost rows interrupts; a clean one does not, per T4.5) —
|
||||
lives here once.
|
||||
|
||||
Classic script, no modules: exposes window.WPListImport. */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
window.WPListImport = function (cfg) {
|
||||
// cfg.prefix DOM id prefix: '<p>-paste', '<p>-file', '<p>-file-btn',
|
||||
// '<p>-check-btn', '<p>-import-btn', '<p>-sample-btn',
|
||||
// '<p>-report', '<p>-add-name', '<p>-add-btn', '<p>-add-err',
|
||||
// '<p>-tool', '<p>-noproject'
|
||||
// cfg.api(suffix) URL builder for the project-scoped routes
|
||||
// cfg.projectId() current project id ('' = not opened from a project)
|
||||
// cfg.sample text the sample button loads
|
||||
// cfg.loadKey response key holding the rows ('nodes' | 'items')
|
||||
// cfg.render() paints the current list from state.rows
|
||||
// cfg.rejectedRow(r) <li> HTML for one rejected row
|
||||
// cfg.duplicateRow(r) <li> HTML for one duplicate row
|
||||
// cfg.addPayload() reads the add-row; {payload} to POST or {error}
|
||||
// cfg.addedMessage(body) confirmation HTML after a successful add
|
||||
// cfg.noProjectMessage what to say when there is no project
|
||||
// cfg.esc the page's escaper
|
||||
var p = cfg.prefix;
|
||||
var esc = cfg.esc;
|
||||
var state = { rows: [], loaded: false };
|
||||
|
||||
// Every message goes through here so the role is decided in one place:
|
||||
// a report that lost rows interrupts (T4.5), a clean one does not.
|
||||
function say(html, isProblem) {
|
||||
var box = el(p + '-report');
|
||||
if (!box) return;
|
||||
box.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||
box.innerHTML = html || '';
|
||||
box.classList.toggle('is-problem', !!isProblem);
|
||||
}
|
||||
|
||||
function setAddError(msg) {
|
||||
var box = el(p + '-add-err');
|
||||
if (box) box.textContent = msg || '';
|
||||
var input = el(p + '-add-name');
|
||||
if (input) {
|
||||
if (msg) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function load(force) {
|
||||
var tool = el(p + '-tool');
|
||||
var warn = el(p + '-noproject');
|
||||
var pid = cfg.projectId();
|
||||
if (!pid) {
|
||||
if (tool) tool.style.display = 'none';
|
||||
if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; }
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (tool) tool.style.display = '';
|
||||
if (warn) warn.style.display = 'none';
|
||||
if (state.loaded && !force) return Promise.resolve();
|
||||
return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); })
|
||||
.catch(function (err) {
|
||||
state.loaded = false;
|
||||
cfg.render();
|
||||
say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline')
|
||||
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function report(res) {
|
||||
var bits = [];
|
||||
var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||
var verb = res.dry_run ? 'would be added' : 'added';
|
||||
var nCreated = (res.created || []).length;
|
||||
bits.push('<p class="loc-report-line"><strong>' + res.read + ' row' + (res.read === 1 ? '' : 's')
|
||||
+ ' read.</strong> ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb
|
||||
+ ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '')
|
||||
+ '.</p>');
|
||||
var rowList = function (title, rows, fmt) {
|
||||
if (!rows || !rows.length) return '';
|
||||
return '<div class="loc-report-group"><div class="loc-report-title">' + esc(title)
|
||||
+ ' (' + rows.length + ')</div><ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow));
|
||||
if (!problem && !nCreated && !(res.reactivated || []).length) {
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
if (!cfg.projectId()) { load(); return; }
|
||||
say('Checking…', false);
|
||||
fetch(cfg.api('/import'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return;
|
||||
}
|
||||
report(res.body);
|
||||
if (!dryRun) return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline')
|
||||
+ '. Nothing was imported.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function add() {
|
||||
var read = cfg.addPayload();
|
||||
if (read.error) {
|
||||
setAddError(read.error);
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
setAddError('');
|
||||
fetch(cfg.api(''), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.value = '';
|
||||
say(cfg.addedMessage(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); });
|
||||
}
|
||||
|
||||
function patch(id, patchBody, describe) {
|
||||
return fetch(cfg.api('/' + encodeURIComponent(id)), {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return load(true);
|
||||
}
|
||||
say(describe(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true);
|
||||
});
|
||||
}
|
||||
|
||||
function wire() {
|
||||
var paste = el(p + '-paste');
|
||||
var file = el(p + '-file');
|
||||
var btn = function (suffix) { return el(p + suffix); };
|
||||
if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); });
|
||||
if (file) file.addEventListener('change', function (ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
// One parser, on the server. Reading the file here and posting its text
|
||||
// is what stops "what does a blank column mean" having two answers.
|
||||
if (paste) paste.value = String(reader.result || '');
|
||||
say('Read <strong>' + esc(f.name) + '</strong>. Check it, then import.', false);
|
||||
};
|
||||
reader.onerror = function () { say('⚠ Could not read that file.', true); };
|
||||
reader.readAsText(f);
|
||||
ev.target.value = '';
|
||||
});
|
||||
if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); });
|
||||
if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); });
|
||||
if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () {
|
||||
if (paste) paste.value = cfg.sample;
|
||||
say('Sample values loaded into the box — obviously fake, and safe to import '
|
||||
+ 'on a throwaway project.', false);
|
||||
});
|
||||
if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add);
|
||||
}
|
||||
|
||||
return { state: state, say: say, setAddError: setAddError, load: load,
|
||||
importText: importText, add: add, patch: patch, wire: wire };
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user