Table sorting preferences will be saved and reused based on the user’s selections

This commit is contained in:
2026-10-25 00:39:55 -03:00
parent 19edee4e11
commit 0f1f399d1a
40 changed files with 896 additions and 230 deletions

View File

@@ -59,7 +59,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
checkIcon(ramp, "ramp", "1 | 2 | 3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
checkIcon(missingRamp, "ramp", "N/D");
checkIcon(missingRamp, "ramp", "N/A");
assert.equal(missingRamp.attributes["data-missing"], "true");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [

View File

@@ -16,7 +16,7 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
@@ -29,7 +29,7 @@ test("SR badge shows reported hours, sums linked SRs and preserves missing value
});
test("SR tooltip formats customer deadlines without timezone day shifts", async () => {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/D"], [undefined, "N/D"], ["", "N/D"]]) {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/A"], [undefined, "N/A"], ["", "N/A"]]) {
const app = setup(async () => response({ items: [{ ...sr("A"), CustomerDeadline_c: value }], hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));

View File

@@ -44,7 +44,7 @@ test("request summaries show duration after status, including zero, failure and
const h = fixture();
h.add("duration", "requestOpportunities", 1);
const entry = h.app.debugState.requestLog.at(-1);
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/A"]]) {
Object.assign(entry, { durationMs, status });
h.app.debugContentView.renderDebugPanel();
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");

View File

@@ -92,7 +92,9 @@ class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
querySelector(selector) { return walk(this).find(node => node.attributes["data-sort-key"] === selector.match(/data-sort-key="([^"]+)"/)?.[1]); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
@@ -100,13 +102,28 @@ class Element {
get isConnected() { return Boolean(this.parent); }
click() { return this.listeners.click(); }
}
function setup() {
function setup(localStorage) {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
return { app, overlay, document };
}
test("installBase sorts typed values and restores the selected header across application instances", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [{ productName: "Ten", quantity: 10 }, { productName: "Two", quantity: 2 }] ;
const first = setup(storage);
first.app.installBaseView.openInstallBaseModal(customer).render(items);
walk(first.overlay).find(node => node.attributes["data-sort-key"] === "quantity").click();
const second = setup(storage);
second.app.installBaseView.openInstallBaseModal(customer).render(items);
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
assert.equal(rows[0].children[7].textContent, "2");
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "quantity").parent.attributes["aria-sort"], "ascending");
assert.equal(items[0].quantity, 10);
});
const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
const walk = node => [node, ...node.children.flatMap(walk)];

View File

@@ -56,6 +56,7 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
const calls = [], notifications = [];
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete,
tableSorting: factory("src/features/preferences/table-sorting.js", "createTableSorting")({ environment: { document, localStorage } }),
notify: (message, isError = false) => notifications.push({ message, isError }),
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
app.setReady(true);
@@ -64,6 +65,27 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
return { app, calls, nodes, find, host, notifications };
}
test("sorting Manage Time preserves notes, hours and deletion IDs and restores on reopening", async () => {
const deleted = [], saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const records = [
{ id: 7, srNumber: "SR002", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, notes: "Second", tag: "SERVICE" },
{ id: 8, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 3, notes: "First", tag: "SERVICE" }
];
const h = setup(undefined, undefined, undefined, async () => records, storage, undefined, async id => deleted.push(id));
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Sort by Service Request").click();
const selects = () => h.nodes().filter(node => node.tag === "select" && node.attributes["aria-label"]?.startsWith("Service Request,"));
assert.equal(selects()[0].value, "SR001");
assert.equal(h.nodes().find(node => node.tag === "textarea" && node.attributes["aria-label"] === "Notes, row 2").value, "First");
assert.equal(h.find("Hours 12-OCT-2026, row 2").value, "3");
h.app.close(); h.app.open(); h.calls[1].resolve(week()); await flush();
assert.equal(selects()[0].value, "SR001");
h.find("Delete row 2").click(); h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["8"]);
assert.equal(selects()[0].value, "SR002");
});
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
const deleted = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id));

View File

@@ -53,6 +53,18 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
return h;
}
test("calendar controls, accessibility labels and missing dates are English", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
const { options } = h.activate();
assert.equal(options.locale, "en");
assert.deepEqual(plain(options.buttonText), { today: "Today", month: "Month", week: "Week" });
assert.deepEqual(plain(options.buttonHints), { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" });
assert.equal(options.moreLinkHint(1), "View 1 more opportunity");
assert.equal(options.moreLinkHint(3), "View 3 more opportunities");
assert.ok(h.nodes().some(node => node.attributes["aria-label"] === "Opportunity views"));
assert.ok(h.nodes().some(node => node.textContent === "No valid Close Date (1)"));
});
test("close dates retain the API day across offsets and reject invalid dates", () => {
const { api } = setup();
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
@@ -81,10 +93,10 @@ test("daily count badges follow filters and are cleaned up when cells unmount",
const badge = frame.children[0];
assert.equal(badge.textContent, "3");
assert.equal(badge.hidden, false);
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
assert.equal(badge.attributes["aria-label"], "3 opportunities");
h.api.render([h.state.items[0]], h.state);
assert.equal(badge.textContent, "1");
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
assert.equal(badge.attributes["aria-label"], "1 opportunity");
h.api.render([], h.state);
assert.equal(badge.textContent, "0");
assert.equal(badge.attributes["data-empty"], "true");
@@ -120,7 +132,7 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Overview", "Calendar"]);
assert.equal(tabs[0].attributes["aria-selected"], "true");
tabs[1].click();
const instance = h.instances[0];
@@ -171,7 +183,7 @@ test("undated opportunities remain visible and open read-only details", () => {
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/A"));
assert.equal(h.api.closeDetails(), true);
assert.equal(h.document.activeElement, undated.children[1]);
});
@@ -216,7 +228,7 @@ test("overflow uses our dialog instead of the FullCalendar offsetParent-dependen
test("calendar events show their stage and handle missing stage values", () => {
const h = setup();
const calendar = h.activate();
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/A"]]) {
const item = { ...h.state.items[0], ForecastGroup_c: value };
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
assert.equal(domNodes[0].children.length, 2);

View File

@@ -31,11 +31,11 @@ const synthetic = (number = "TEST-1", name = "Synthetic opportunity") => ({
EffectiveDate: "2026-10-23", LastUpdateDate: "2026-09-22T13:15:00Z", StatusCode: "OPEN", ForecastGroup_c: "FORECAST"
});
function setup(items = [synthetic()]) {
function setup(items = [synthetic()], localStorage) {
const table = new Element("table");
const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
app.currentUser.allowsCrmActivities = () => true;
const summaries = [];
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
@@ -46,6 +46,22 @@ function setup(items = [synthetic()]) {
return { app, table, rows, cell, headers, summaries };
}
test("Overview restores independent column and direction preferences across reloads and new data", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [synthetic("B", "Beta"), synthetic("A", "Alpha")];
const first = setup(items, storage);
first.app.opportunitiesTable.sortOpportunitiesBy("name");
first.app.opportunitiesTable.sortOpportunitiesBy("name");
const second = setup(items.slice().reverse(), storage);
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortKey, "name");
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortDirection, "descending");
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading" });
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
});
test("ten columns retain their links and put product/customer controls below the corresponding text", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ ProdGroupName: "Cloud" }] });
@@ -83,8 +99,8 @@ test("ramp months preserve zero and flag missing values beside the product count
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/D | 6 | 3"]
[[], null], [[null], "N/A"], [[null, undefined, "", " "], "N/A"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -93,7 +109,7 @@ test("ramp months preserve zero and flag missing values beside the product count
if (expected === null) assert.equal(badge(), undefined);
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/A")));
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
@@ -200,7 +216,7 @@ test("product table distinguishes unavailable ramp months from numeric zero", ()
findClass(h.cell("name"), "opportunities-extension-products-count").click();
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
const rows = table.children.find(child => child.tag === "tbody").children;
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/D", "N/D", "N/D", "N/D", "0", "0", "6"]);
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/A", "N/A", "N/A", "N/A", "0", "0", "6"]);
});
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
@@ -239,9 +255,31 @@ test("pending, zero-count, failed and missing-data rows preserve their layout an
assert.equal(h.cell("customer").children[0].children[0].textContent, "-");
});
test("loading, empty and error messages span exactly the visible columns", () => {
test("loading skeleton preserves columns and action slots, then disappears on success or failure", () => {
const h = setup();
for (const status of ["loading", "empty", "error"]) {
for (const nextStatus of ["ready", "empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading", items: [], message: "Loading opportunities..." });
assert.equal(h.table.attributes["aria-busy"], "true");
assert.equal(h.rows().length, 8);
assert.equal(h.headers().length, 10);
assert.ok(h.headers().every(header => header.children[0].disabled));
for (const row of h.rows()) {
assert.equal(row.attributes["aria-hidden"], "true");
assert.deepEqual(row.children.map(cell => cell.attributes["data-column"]), h.headers().map(header => header.attributes["data-column"]));
assert.ok(row.children.every(cell => findClass(cell, "opportunities-extension-account-plans-skeleton-bar")));
assert.equal(walk(row).filter(node => hasClass(node, "opportunities-extension-table-skeleton-badge")).length, 7);
assert.equal(walk(row).some(node => node.tag === "button" || node.tag === "a"), false);
}
h.app.opportunitiesTable.setOpportunitiesTableState({ status: nextStatus, items: nextStatus === "ready" ? [synthetic()] : [], message: "" });
assert.equal(h.table.attributes["aria-busy"], "false");
assert.equal(findClass(h.table, "opportunities-extension-table-skeleton-row"), undefined);
assert.ok(h.headers().every(header => !header.children[0].disabled));
}
});
test("empty and error messages span exactly the visible columns", () => {
const h = setup();
for (const status of ["empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status, message: "Synthetic state" });
assert.equal(h.rows().length, 1);
assert.equal(h.rows()[0].children[0].colSpan, 10);

View File

@@ -90,6 +90,8 @@ function dom() {
set textContent(value) { this.text = String(value); this.children = []; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
@@ -172,6 +174,26 @@ test("single-product opportunity alert opens the comparison directly with opport
assert.equal(host.children.length, 1);
});
test("projection sorting restores on reopening without changing forecast source order", () => {
const { document, host } = dom();
const app = loadApplication("content", { document, Intl, Date });
const input = product({ forecast: [
{ year: 2026, month: 11, adjustedConsumptionAmount: 10 },
{ year: 2026, month: 12, adjustedConsumptionAmount: 20 }
] });
const before = JSON.stringify(input);
const opportunity = { OptyNumber: "TEST" };
app.rampProjectionView.open(input, opportunity);
const button = walk(host).find(node => node.attributes["data-sort-key"] === "month");
button.click(); button.click();
const firstMonth = () => walk(host).find(node => node.tag === "tbody").children[0].children[0].textContent;
const expected = firstMonth();
app.rampProjectionView.close(); app.rampProjectionView.open(input, opportunity);
assert.equal(firstMonth(), expected);
assert.equal(walk(host).find(node => node.attributes["data-sort-key"] === "month").parent.attributes["aria-sort"], "descending");
assert.equal(JSON.stringify(input), before);
});
test("product column opens accessible read-only comparison and restores focus/background on close", () => {
const { document, host, content } = dom();
const app = loadApplication("content", { document, Intl, Date });

View File

@@ -156,6 +156,19 @@ function setup() {
}
const tick = () => new Promise(resolve => setImmediate(resolve));
test("Rate Card restores the last column and direction on reopening", async () => {
const { app, buttons, find, requests } = setup();
const opening = buttons[0].listeners.click(); await tick();
requests[0].resolve({ ok: true, items: [item], debugRequests: [] }); await opening;
find("table").querySelectorAll("th")[9].children[0].listeners.click();
find("table").querySelectorAll("th")[9].children[0].listeners.click();
app.ratecardView.closeRatecardDrawer({ immediate: true });
const reopened = buttons[0].listeners.click(); await tick();
if (requests[1]) requests[1].resolve({ ok: true, items: [item], debugRequests: [] });
await reopened;
assert.equal(find("table").querySelectorAll("th")[9].attributes["aria-sort"], "descending");
});
test("Rate card sits beside Contract ID, opens a skeleton, searches and sorts without HTML injection", async () => {
const { app, root, buttons, find, document, requests, logs } = setup();
assert.equal(buttons.length, 2);

View File

@@ -125,7 +125,9 @@ class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
querySelector(selector) { return walk(this).find(node => node.attributes["data-sort-key"] === selector.match(/data-sort-key="([^"]+)"/)?.[1]); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
@@ -134,13 +136,28 @@ class Element {
click() { return this.listeners.click(); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
function setup() {
function setup(localStorage) {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
return { app, overlay, document };
}
test("serviceRequests sorts typed values and restores the selected header across application instances", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [{ id: "Later", created: "2026-02-01" }, { id: "Earlier", created: "2026-01-31" }] ;
const first = setup(storage);
first.app.serviceRequestsView.openServiceRequestsModal(customer).render(items);
walk(first.overlay).find(node => node.attributes["data-sort-key"] === "created").click();
const second = setup(storage);
second.app.serviceRequestsView.openServiceRequestsModal(customer).render(items);
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
assert.equal(rows[0].children[2].textContent, "Earlier");
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "created").parent.attributes["aria-sort"], "ascending");
assert.equal(items[0].id, "Later");
});
const customer = { CustomerAccount: { PartyNumber: "customer-test", PartyUniqueName: "Synthetic customer" } };
test("service request search covers nested and formatted columns and preserves errors and full results on clearing", () => {

View File

@@ -0,0 +1,44 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const factory = new vm.Script(fs.readFileSync("src/features/preferences/table-sorting.js", "utf8") + "\ncreateTableSorting;").runInNewContext();
const plain = value => JSON.parse(JSON.stringify(value));
const columns = [{ key: "number", type: "number" }, { key: "date", type: "date" }, { key: "name" }];
test("table preferences survive recreation, remain independent and reject obsolete or malformed settings", () => {
const saved = new Map();
const environment = { localStorage: { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) } };
const helper = factory({ environment });
const a = helper.create("a", columns);
a.toggle("number"); a.toggle("number");
const restored = factory({ environment });
assert.deepEqual(plain(restored.read("a", columns)), { key: "number", direction: "descending" });
assert.equal(restored.read("b", columns).key, "");
for (const invalid of ["{", 'null', '{"key":"removed","direction":"ascending"}', '{"key":"number","direction":"wrong"}']) {
saved.set("opportunities-extension-table-sort-v1-b", invalid);
assert.equal(restored.read("b", columns).key, "");
}
a.toggle("unknown");
assert.equal(a.get().key, "number");
});
test("sorting is typed, stable, immutable and keeps missing values last in both directions", () => {
const sorting = factory({ environment: {} }).create("table", columns);
const rows = [{ number: "10", date: "2026-02-01", name: "Item 10" }, { number: 2, date: "2026-01-31", name: "Item 2" }, { number: null }, { number: 2 }];
sorting.toggle("number");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[3], rows[0], rows[2]]);
sorting.toggle("number");
assert.deepEqual([...sorting.select(rows)], [rows[0], rows[1], rows[3], rows[2]]);
sorting.toggle("date");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[0], rows[2], rows[3]]);
sorting.toggle("name");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[0], rows[2], rows[3]]);
assert.equal(rows[0].number, "10");
});
test("blocked storage retains a usable in-memory preference", () => {
const helper = factory({ environment: { get localStorage() { throw new Error("Blocked"); } } });
helper.create("a", columns).toggle("name");
assert.equal(helper.create("a", columns).get().key, "name");
});