feat(classifier): export filtered file list to TSV + direct path bind on paste-back
A round-trip workflow: export the file list, edit it in Excel, paste it back — binding files by exact path or fuzzy name. Export (left / folder tree, Classify mode): - A "⬆ Export list" button in the Show toolbar copies the CURRENT filtered file set (name search + the Show toggles) to TSV — header `path<TAB>file`, files only, no folder rows. It honours every active filter but ignores expand/collapse (display-only), so files inside collapsed folders are included. `path` is the file's root-relative key; `file` is the bare filename. Clipboard copy, with a .tsv download fallback when the clipboard API is blocked (e.g. file://). - Reuses the tree's computeVisible() so the export matches exactly what the filters show. Paste-back (right / "Paste rows"): the Current name column now accepts either form - Bare filename → today's behaviour: fuzzy name match / drop later. - Full path (the exported `path`) → binds that EXACT file directly on paste: proposeMatches detects currentName === a file's key as a confidence-1 `via:'path'` match (the strongest signal), folded into the existing auto-assign pass. A path that matches nothing falls back to its basename for fuzzy matching. - A pasted full path on a row with NO tracking number yet is still CLAIMED (recorded in row.bound); when a tracking/rev later lands, restampRow places the claimed file onto the new leaf. (User choice A.) Tests: export TSV honours the filter + includes collapsed folders; a full-path Current name binds the exact file (and leaves others untouched); a path with no tracking is claimed then placed once tracking is filled. 69 classify green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e32258fa9f
commit
1bb5d1ad97
7 changed files with 188 additions and 5 deletions
|
|
@ -160,6 +160,7 @@
|
||||||
}
|
}
|
||||||
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
|
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
|
||||||
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
|
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
|
||||||
|
.export-list-btn { margin-left: auto; } /* push the export action to the toolbar's right edge */
|
||||||
|
|
||||||
/* Live filter box above a file tree. */
|
/* Live filter box above a file tree. */
|
||||||
.tree-filter {
|
.tree-filter {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@
|
||||||
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
||||||
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
||||||
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
||||||
|
exportListBtn: document.getElementById('exportListBtn'),
|
||||||
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
|
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
|
||||||
importDatasetBtn: document.getElementById('importDatasetBtn'),
|
importDatasetBtn: document.getElementById('importDatasetBtn'),
|
||||||
importDatasetInput: document.getElementById('importDatasetInput'),
|
importDatasetInput: document.getElementById('importDatasetInput'),
|
||||||
|
|
@ -366,6 +367,11 @@
|
||||||
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
||||||
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
||||||
|
|
||||||
|
// Export the filtered file list (path + file TSV) for the Excel round-trip.
|
||||||
|
if (app.dom.exportListBtn) app.dom.exportListBtn.addEventListener('click', function () {
|
||||||
|
if (app.modules.tree && app.modules.tree.exportFilteredList) app.modules.tree.exportFilteredList();
|
||||||
|
});
|
||||||
|
|
||||||
// Collapse tree button
|
// Collapse tree button
|
||||||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -594,8 +594,17 @@
|
||||||
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
|
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
|
||||||
}
|
}
|
||||||
function assignFromRow(keys, row) {
|
function assignFromRow(keys, row) {
|
||||||
|
if (!keys || !keys.length) return;
|
||||||
var leaf = leafForRow(row);
|
var leaf = leafForRow(row);
|
||||||
if (!leaf || !keys || !keys.length) return;
|
if (!leaf) {
|
||||||
|
// No tracking number on the row yet — still CLAIM these files for it
|
||||||
|
// (e.g. a pasted full path on a row whose tracking is still blank). The
|
||||||
|
// binding is recorded in row.bound; when a tracking/rev later lands,
|
||||||
|
// restampRow places the claimed files onto the new leaf.
|
||||||
|
keys.forEach(function (k) { row.placed[k] = true; (row.bound || (row.bound = Object.create(null)))[k] = true; });
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
place(keys, leaf, 'tracking');
|
place(keys, leaf, 'tracking');
|
||||||
keys.forEach(function (k) {
|
keys.forEach(function (k) {
|
||||||
row.placed[k] = true;
|
row.placed[k] = true;
|
||||||
|
|
@ -629,12 +638,17 @@
|
||||||
if (!keys.length) return;
|
if (!keys.length) return;
|
||||||
var leaf = leafForRow(row);
|
var leaf = leafForRow(row);
|
||||||
if (!leaf) return;
|
if (!leaf) return;
|
||||||
var old = Object.create(null);
|
var old = Object.create(null), toPlace = [];
|
||||||
keys.forEach(function (k) {
|
keys.forEach(function (k) {
|
||||||
var a = state.assignments[k];
|
var a = state.assignments[k];
|
||||||
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
|
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
|
||||||
|
else if (row.bound && row.bound[k]) toPlace.push(k); // claimed by path, not yet placed → place now
|
||||||
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
|
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
|
||||||
});
|
});
|
||||||
|
if (toPlace.length) {
|
||||||
|
place(toPlace, leaf, 'tracking');
|
||||||
|
if (row.title && row.title.trim()) toPlace.forEach(function (k) { var aa = state.assignments[k]; if (aa && !aa.titleOverride) setTitleOverride(k, row.title); });
|
||||||
|
}
|
||||||
clearHashConflicts();
|
clearHashConflicts();
|
||||||
Object.keys(old).forEach(pruneEmptyTrackingChain);
|
Object.keys(old).forEach(pruneEmptyTrackingChain);
|
||||||
notify();
|
notify();
|
||||||
|
|
@ -699,6 +713,7 @@
|
||||||
});
|
});
|
||||||
return { rows: rows, skipped: skipped };
|
return { rows: rows, skipped: skipped };
|
||||||
}
|
}
|
||||||
|
function baseName(s) { return String(s == null ? '' : s).split(/[\/\\]/).pop(); }
|
||||||
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
|
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
|
||||||
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
|
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
|
||||||
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
|
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
|
||||||
|
|
@ -733,9 +748,15 @@
|
||||||
var out = [];
|
var out = [];
|
||||||
(files || []).forEach(function (f) {
|
(files || []).forEach(function (f) {
|
||||||
var full = zddc.joinExtension(f.originalFilename, f.extension);
|
var full = zddc.joinExtension(f.originalFilename, f.extension);
|
||||||
|
var key = srcKeyForFile(f);
|
||||||
var best = null;
|
var best = null;
|
||||||
named.forEach(function (r) {
|
named.forEach(function (r) {
|
||||||
var s = nameScore(r.currentName, full);
|
// A pasted FULL PATH equal to this file's key → an exact, direct
|
||||||
|
// bind (the strongest signal — wins over any name score).
|
||||||
|
if (r.currentName === key) { best = { row: r, confidence: 1, via: 'path' }; return; }
|
||||||
|
// Otherwise score on the name; a path that didn't match exactly is
|
||||||
|
// reduced to its basename so the fuzzy name match still applies.
|
||||||
|
var s = nameScore(baseName(r.currentName), full);
|
||||||
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
|
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
|
||||||
});
|
});
|
||||||
if (!best) { // fallback: tracking number in the filename
|
if (!best) { // fallback: tracking number in the filename
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@
|
||||||
}
|
}
|
||||||
function openPasteDialog(prefill) {
|
function openPasteDialog(prefill) {
|
||||||
var c = C();
|
var c = C();
|
||||||
var m = scratchModal('Paste rows from Excel', 'Fixed columns, tab-separated as Excel copies: Tracking number · Rev (Status) · Title · Current name. A header row is skipped. The current name is matched against your files — exact matches are assigned automatically.');
|
var m = scratchModal('Paste rows from Excel', 'Fixed columns, tab-separated as Excel copies: Tracking number · Rev (Status) · Title · Current name. A header row is skipped. Current name accepts a bare filename (matched against your files — exact name matches are assigned automatically) OR a full path from “⬆ Export list” (binds that exact file directly on paste).');
|
||||||
var ta = document.createElement('textarea');
|
var ta = document.createElement('textarea');
|
||||||
ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false;
|
ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false;
|
||||||
ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf';
|
ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf';
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,68 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Export the filtered file list to TSV (path + file) ──────────────────
|
||||||
|
// Every file passing the CURRENT tree filters (name search + the Show
|
||||||
|
// toggles), across the WHOLE tree — expand/collapse is display-only, so a
|
||||||
|
// collapsed folder's files are included just the same. `path` is the file's
|
||||||
|
// root-relative key (paste it into "Current name" to bind that exact file);
|
||||||
|
// `file` is the bare filename (paste it for a name to match/drop later).
|
||||||
|
function filteredFileObjects() {
|
||||||
|
var c = window.app.modules.classify;
|
||||||
|
var vis = anyFilter() ? computeVisible() : null;
|
||||||
|
var out = [];
|
||||||
|
(function walk(nodes) {
|
||||||
|
(nodes || []).forEach(function (n) {
|
||||||
|
(n.files || []).forEach(function (f) {
|
||||||
|
var show = vis ? !!vis.files[c.srcKeyForFile(f)] : classifyAllows(f);
|
||||||
|
if (show) out.push(f);
|
||||||
|
});
|
||||||
|
walk(n.children);
|
||||||
|
});
|
||||||
|
})(window.app.folderTree || []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function buildExportTsv() {
|
||||||
|
var c = window.app.modules.classify;
|
||||||
|
var files = filteredFileObjects().slice().sort(function (a, b) {
|
||||||
|
return cmpName(c.srcKeyForFile(a), c.srcKeyForFile(b));
|
||||||
|
});
|
||||||
|
var lines = ['path\tfile'];
|
||||||
|
files.forEach(function (f) {
|
||||||
|
lines.push(c.srcKeyForFile(f) + '\t' + window.zddc.joinExtension(f.originalFilename, f.extension));
|
||||||
|
});
|
||||||
|
return { tsv: lines.join('\n'), count: files.length };
|
||||||
|
}
|
||||||
|
function exportFilteredList() {
|
||||||
|
var built = buildExportTsv();
|
||||||
|
if (!built.count) { window.zddc.toast('No files to export — nothing passes the current filters.', 'info'); return; }
|
||||||
|
copyOrDownload(built.tsv, built.count);
|
||||||
|
}
|
||||||
|
function copyOrDownload(text, count) {
|
||||||
|
function ok() { window.zddc.toast('Copied ' + count + ' file' + (count === 1 ? '' : 's') + ' (path + file) — paste into Excel.', 'success'); }
|
||||||
|
function download() {
|
||||||
|
try {
|
||||||
|
var blob = new Blob([text], { type: 'text/tab-separated-values' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a'); a.href = url; a.download = 'classifier-files.tsv';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
||||||
|
window.zddc.toast('Clipboard unavailable — downloaded classifier-files.tsv instead.', 'info');
|
||||||
|
} catch (e) { window.zddc.toast('Could not copy or download the list — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(ok, download);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||||
|
var done = document.execCommand('copy'); ta.remove();
|
||||||
|
done ? ok() : download();
|
||||||
|
} catch (e) { download(); }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the folder tree
|
* Render the folder tree
|
||||||
*/
|
*/
|
||||||
|
|
@ -941,6 +1003,8 @@
|
||||||
selectAll,
|
selectAll,
|
||||||
revealFile,
|
revealFile,
|
||||||
setShowFilters,
|
setShowFilters,
|
||||||
setNameFilter
|
setNameFilter,
|
||||||
|
exportFilteredList,
|
||||||
|
_buildExportTsv: buildExportTsv
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@
|
||||||
<input type="checkbox" id="showEmptyCheckbox" checked>
|
<input type="checkbox" id="showEmptyCheckbox" checked>
|
||||||
Empty
|
Empty
|
||||||
</label>
|
</label>
|
||||||
|
<button class="btn btn-sm export-list-btn" id="exportListBtn"
|
||||||
|
title="Copy the filtered file list (path + file columns, no folders) as TSV — paste into Excel, edit, then paste back via “Paste rows”. Paste a full path into the Current name column to bind that exact file.">⬆ Export list</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
|
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
|
||||||
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
|
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
|
||||||
|
|
|
||||||
|
|
@ -1643,3 +1643,92 @@ test('From a list: dir-picker resolves the topmost ticked directories only', asy
|
||||||
// ticked but its child B/y is, so B/y is included; C contributes nothing.
|
// ticked but its child B/y is, so B/y is included; C contributes nothing.
|
||||||
expect(r).toEqual(['A', 'B/y']);
|
expect(r).toEqual(['A', 'B/y']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Export filtered list → Excel round-trip (path + file TSV) ──────────────
|
||||||
|
|
||||||
|
test('export: filtered file list → TSV (path + file), includes collapsed folders', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify, tree = window.app.modules.tree;
|
||||||
|
c.reset();
|
||||||
|
const a = { originalFilename: 'pump report', extension: 'pdf', folderPath: 'Root/Elec' };
|
||||||
|
const b = { originalFilename: 'valve spec', extension: 'pdf', folderPath: 'Root/Elec' };
|
||||||
|
const d = { originalFilename: 'civil note', extension: 'pdf', folderPath: 'Root/Civ' };
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [], children: [
|
||||||
|
// COLLAPSED — its files must STILL export (collapse is display-only).
|
||||||
|
{ name: 'Elec', path: 'Root/Elec', expanded: false, scanState: 'done', children: [], files: [a, b] },
|
||||||
|
{ name: 'Civ', path: 'Root/Civ', expanded: true, scanState: 'done', children: [], files: [d] },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
tree.render();
|
||||||
|
const all = tree._buildExportTsv();
|
||||||
|
tree.setNameFilter('valve');
|
||||||
|
const filtered = tree._buildExportTsv();
|
||||||
|
tree.setNameFilter('');
|
||||||
|
return { all: all.tsv, allCount: all.count, filtered: filtered.tsv, filteredCount: filtered.count };
|
||||||
|
});
|
||||||
|
// No filter: header + all three files, even though Elec is collapsed. The root
|
||||||
|
// segment ('Root') is stripped from the key, matching srcKeyForFile.
|
||||||
|
expect(r.allCount).toBe(3);
|
||||||
|
expect(r.all.split('\n')[0]).toBe('path\tfile');
|
||||||
|
expect(r.all).toContain('Elec/pump report.pdf\tpump report.pdf');
|
||||||
|
expect(r.all).toContain('Civ/civil note.pdf\tcivil note.pdf');
|
||||||
|
// Name filter applied → only the matching file is exported.
|
||||||
|
expect(r.filteredCount).toBe(1);
|
||||||
|
expect(r.filtered).toContain('Elec/valve spec.pdf\tvalve spec.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paste rows: a full-path Current name binds that exact file directly', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
c.reset();
|
||||||
|
const f1 = { originalFilename: 'IMG_001', extension: 'pdf', folderPath: 'Job/Inbox' };
|
||||||
|
const f2 = { originalFilename: 'IMG_002', extension: 'pdf', folderPath: 'Job/Inbox' };
|
||||||
|
window.app.folderTree = [{ name: 'Job', path: 'Job', files: [], children: [
|
||||||
|
{ name: 'Inbox', path: 'Job/Inbox', files: [f1, f2], children: [] }] }];
|
||||||
|
const k1 = c.srcKeyForFile(f1); // 'Inbox/IMG_001.pdf'
|
||||||
|
c.setWorklist([{ id: 'r1', trackingNumber: 'ACME-MECH-0001', revisionCell: 'A (IFR)', title: 'Pump', currentName: k1, source: { pasted: true } }]);
|
||||||
|
const wl = c.getWorklist();
|
||||||
|
const props = c.proposeMatches([f1, f2], wl, {});
|
||||||
|
const pathProp = props.filter((p) => c.srcKeyForFile(p.file) === k1)[0];
|
||||||
|
props.filter((p) => p.auto).forEach((p) => c.assignFromRow([c.srcKeyForFile(p.file)], p.row));
|
||||||
|
const d1 = c.deriveTarget(f1);
|
||||||
|
return {
|
||||||
|
via: pathProp && pathProp.via, auto: pathProp && pathProp.auto,
|
||||||
|
tracking: d1.tracking, rev: d1.revision,
|
||||||
|
f2placed: !!(c.getAssignment(c.srcKeyForFile(f2)) || {}).trackingNodeId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.via).toBe('path'); // exact key → path match
|
||||||
|
expect(r.auto).toBe(true); // unambiguous → auto-assigned
|
||||||
|
expect(r.tracking).toBe('ACME-MECH-0001'); // f1 placed directly from the pasted path
|
||||||
|
expect(r.rev).toBe('A');
|
||||||
|
expect(r.f2placed).toBe(false); // the other file is untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paste rows: a full path with no tracking yet is claimed, then placed when tracking lands', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
c.reset();
|
||||||
|
const f = { originalFilename: 'scan99', extension: 'pdf', folderPath: 'Job/Inbox' };
|
||||||
|
window.app.folderTree = [{ name: 'Job', path: 'Job', files: [], children: [
|
||||||
|
{ name: 'Inbox', path: 'Job/Inbox', files: [f], children: [] }] }];
|
||||||
|
const key = c.srcKeyForFile(f);
|
||||||
|
c.setWorklist([{ id: 'r1', trackingNumber: '', revisionCell: '', title: 'Later', currentName: key, source: { pasted: true } }]);
|
||||||
|
const wl = c.getWorklist();
|
||||||
|
c.proposeMatches([f], wl, {}).filter((p) => p.auto).forEach((p) => c.assignFromRow([c.srcKeyForFile(p.file)], p.row));
|
||||||
|
const claimedBefore = !!c.getWorklistRow('r1').placed[key];
|
||||||
|
const placedBefore = !!(c.getAssignment(key) || {}).trackingNodeId;
|
||||||
|
c.setRowTracking('r1', 'ACME-MECH-0007'); // tracking lands → the claim is placed
|
||||||
|
c.setRevisionCell('r1', 'B (IFC)');
|
||||||
|
const d = c.deriveTarget(f);
|
||||||
|
return { claimedBefore, placedBefore, tracking: d.tracking, rev: d.revision };
|
||||||
|
});
|
||||||
|
expect(r.claimedBefore).toBe(true); // claimed even with no tracking number (choice A)
|
||||||
|
expect(r.placedBefore).toBe(false); // but not yet placed — no leaf to place onto
|
||||||
|
expect(r.tracking).toBe('ACME-MECH-0007'); // placed once the tracking number is filled in
|
||||||
|
expect(r.rev).toBe('B');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue