Implemented filters for Service Requests and Actions

This commit is contained in:
2026-09-25 16:37:29 -03:00
parent cc567c8ff1
commit de4da0eec6
26 changed files with 1001 additions and 99 deletions

View File

@@ -4,12 +4,14 @@ const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function setup(family, saved = {}, principal = "user@example.invalid") {
const local = {
get: async key => ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values)))
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
remove: async keys => keys.forEach(key => delete saved[key])
};
const api = family === "firefox" ? { browser: { storage: { local } } } : {
chrome: { runtime: {}, storage: { local: {
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done)
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
remove: (keys, done) => local.remove(keys).then(done)
} } }
};
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } });
@@ -41,6 +43,34 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
return { app, calls, load, saved, local };
}
for (const family of ["chrome", "firefox"]) {
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
const h = setup(family);
const currentStamp = Date.now() - 2000;
const nextStamp = currentStamp + 1000;
let hours = 2;
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
h.calls.push("sr");
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
};
await h.load(currentStamp, false);
hours = 8;
await h.load(nextStamp, false);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
const count = h.calls.length;
await h.load(currentStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (2h)");
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
h.app.activityFilters.toggle("sr", "under4");
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
await h.load(nextStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
const reopened = setup(family, h.saved);
await reopened.load(currentStamp, true);
assert.deepEqual(reopened.calls, []);
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
});
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
const h = setup(family);
const stamp = Date.now() - 1000;
@@ -78,6 +108,26 @@ test("TTL follows product preferences, rejects stale/mismatched cache and stores
await h.load(stamp + 1, true);
assert.deepEqual(h.calls, []);
});
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
const h = setup("firefox");
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
const stamp = Date.now() - 1000;
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
const expiredStamp = Date.now() - 25 * 3600000;
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
h.saved.preferences = { keep: true };
await h.app.activityCache.write("sr", "all", stamp + 1, []);
assert.equal(h.saved[expiredKey], undefined);
assert.ok(h.saved[legacyKey]);
assert.equal(h.saved.preferences.keep, true);
await h.app.activityCache.invalidate();
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
assert.equal(h.saved.preferences.keep, true);
});
test("storage failure does not lose live activity results and failures are never cached", async () => {
const h = setup("firefox");
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };

View File

@@ -0,0 +1,201 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, handler) { this.listeners[key] = handler; }
}
function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
let renders = 0;
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
const sr = new Map(), actions = new Map();
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
const controls = app.activityFilters.createControls(form, status, stage);
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
matches: number => app.activityFilters.matches({ OptyNumber: number }),
click: (kind, value) => button(kind, value).listeners.click() };
}
test("activity badge filters precede Status, are accessible and combine without network", () => {
const h = setup();
assert.equal(h.controls.children[2], h.status);
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
assert.equal(h.controls.children[3], h.stage);
assert.equal(h.form.attributes["data-activity-filters"], "true");
assert.equal(h.button("sr", "matched").textContent, "\u2705 SR");
assert.equal(h.button("actions", "missing").textContent, "\u26d4 Action");
assert.equal(h.button("actions", "matched").type, "button");
h.sr.set("A", { state: "matched" });
h.actions.set("A", { state: "missing" });
h.sr.set("B", { state: "missing" });
h.actions.set("B", { state: "matched" });
assert.equal(h.matches("unknown"), true);
h.click("sr", "missing");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
assert.equal(h.matches("unknown"), false);
h.click("actions", "missing");
assert.equal(h.matches("A"), false, "SR and Action use AND");
h.actions.set("A", { state: "matched" });
assert.equal(h.matches("A"), true);
assert.equal(h.renders(), 2);
});
test("SR below four hours is exclusive and regular SR options can replace it", () => {
const h = setup();
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
h.click("sr", "under4");
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
h.click("sr", "matched");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("B"), true);
h.click("sr", "matched");
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
h.click("sr", "under4");
h.click("sr", "missing");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("A"), false);
});
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
const h = setup();
h.click("sr", "matched");
h.click("actions", "matched");
for (const state of ["idle", "loading", "error", "matched"]) {
h.sr.set("A", { state });
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), false);
h.sr.set("A", { state: "missing" });
h.actions.set("A", { state });
assert.equal(h.matches("A"), false);
}
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), true);
h.app.currentUser.allowsCrmActivities = () => false;
h.app.activityFilters.render();
assert.equal(h.matches("unknown"), true);
assert.equal(h.controls.children[0].hidden, true);
assert.equal(h.controls.children[1].hidden, true);
assert.equal(h.form.attributes["data-activity-filters"], "false");
});
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
const calls = [];
let finish;
app.platformContentMessages.sendRuntimeMessage = message => {
calls.push(message.type);
return new Promise(resolve => { finish = resolve; });
};
app.opportunitiesTable.renderOpportunitiesTable = () => {};
const form = new Element("form");
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } });
await identity;
app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed);
assert.equal(controls.children[1].hidden, !allowed);
if (!allowed) {
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
app.activityFilters.toggle("sr", "under4");
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
}
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
});
}
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
const app = loadApplication("content");
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.activityProgress.update = () => {};
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
];
await app.opportunityCrmServiceRequests.load("synthetic", 1);
for (const [index, [, expected]] of examples.entries()) {
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
}
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
});
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {};
app.authSessionState.principal = "me@example.invalid";
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
const products = { items: [{ RevnId: "123" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
await app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
app.authSessionState.principal = "other@example.invalid";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
app.authSessionState.principal = "";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
});
test("activity filters compose with existing search, status, owner and customer filters", () => {
const h = setup();
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
h.app.opportunitiesFilters.resetCustomerFilter(items);
h.app.opportunitiesFilters.resetOwnerFilter(items);
h.sr.set("A", { state: "matched" });
h.sr.set("B", { state: "missing" });
h.click("sr", "missing");
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
assert.deepEqual(results(), ["A"]);
h.app.opportunitiesFilterState.tableSearch = "Test B";
assert.deepEqual(results(), []);
h.app.opportunitiesFilterState.tableSearch = "";
h.app.opportunitiesFilterState.selectedStatuses.clear();
assert.deepEqual(results(), []);
});
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /activity-filter-button:focus-visible/);
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
});
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
const breakpoint = css.indexOf("@media (max-width: 1350px)");
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
});

View File

@@ -20,6 +20,19 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("request summaries show duration after status, including zero, failure and pending", () => {
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"]]) {
Object.assign(entry, { durationMs, status });
h.app.debugContentView.renderDebugPanel();
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
assert.equal(meta.children.at(-1).textContent, expected);
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
}
});
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");

View File

@@ -69,10 +69,11 @@ test("ten columns retain their links and put product/customer controls below the
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
});
test("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
test("all four filter fields share available width dynamically with responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
assert.match(css, /@media \(max-width: 1150px\)/);
});

View File

@@ -2,6 +2,83 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
for (const first of ["SR", "Actions"]) {
test(`Last update waits for every background call when ${first} finishes first`, async () => {
const h = setup();
const sr = deferred(), actions = deferred();
let renders = 0;
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
const id = h.start();
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.progress(id, { state: "complete" });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? sr : actions).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? actions : sr).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
assert.equal(renders, 1, "publish the completed cycle once");
});
}
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
const h = setup();
const old = deferred(), current = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => old.promise;
const first = h.start();
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
h.app.opportunityCrmServiceRequests.load = () => current.promise;
const second = h.start();
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
old.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
current.reject(new Error("Synthetic failure"));
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 300);
});
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
const h = setup();
const restore = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.app.opportunitiesQueryState.periodRequestVersion += 1;
restore.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
const next = h.start();
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
test("Last update completes for SALES_REP without activity requests", async () => {
const h = setup();
h.app.opportunityCrmServiceRequests.load = () => undefined;
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
function setup() {
let listener;

View File

@@ -72,10 +72,12 @@ test("Refresh all deduplicates clicks, clears memory before reload and preserves
app.authSessionState.accessToken = "synthetic";
app.preferencesState.opptyProductsCacheHours = 48;
app.productsContentState.opptyProductsByNumber.set("A", {});
app.productsContentState.productsLastCachedAt = 100;
app.consumptionTrendState.consumptionTrendCache.set("A", {});
const first = app.refreshAll.refresh("Next Quarter");
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
assert.deepEqual(calls, ["activities"]);
finish();

View File

@@ -62,6 +62,44 @@ function contentFixture(send) {
return app;
}
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
const pending = [];
let finishWrite;
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
app.activityCache.read = async () => null;
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
let complete = false, sharedComplete = false;
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
await flush();
assert.equal(pending.length, 4);
pending.slice(0, 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
await flush();
assert.equal(pending.length, 5);
assert.equal(complete, false);
assert.equal(sharedComplete, false);
pending[4]({ ok: true, actions: [], hasNext: false });
await flush();
assert.equal(complete, false, "cache write still pending");
finishWrite();
await batch;
await shared;
assert.equal(complete, true);
assert.equal(sharedComplete, true);
});
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
const pending = [];
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
await flush();
app.opportunityWorkloadActions.reset(false);
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
await batch;
assert.equal(pending.length, 4);
});
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = [];
const app = contentFixture(async message => {