Visual enhancements for dark mode, along with search functionality for service requests and installed base

This commit is contained in:
2026-09-23 10:56:51 -03:00
parent b4d8d9dbc1
commit 6c5db94aaa
17 changed files with 436 additions and 83 deletions

View File

@@ -39,6 +39,14 @@ runtime module loader, browser-side eval, ES-module requirement or new dependenc
## Tests and Maintenance ## Tests and Maintenance
Installed Base and Service Requests include a search input above their tables.
Search filters loaded records across every displayed column, including raw and
formatted numbers/dates, without additional API calls. Matching ignores case
and accents; all whitespace-separated terms must match somewhere in the row.
The result count updates as users type. Clearing the input restores all records;
reopening a dialog starts with an empty search. Search remains available during
loading and retains the entered terms when results arrive. Both themes are supported.
Each Cloud Consumption plan includes a **Rate card** button beside Contract ID. Each Cloud Consumption plan includes a **Rate card** button beside Contract ID.
It opens an animated right-side drawer with skeleton rows, an all-column search It opens an animated right-side drawer with skeleton rows, an all-column search
and sortable headers. The drawer reuses the Cloud Consumption header component and sortable headers. The drawer reuses the Cloud Consumption header component

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`; return `// Source: ${file}\n${source}`;
}); });
if (target === "content") { if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css"] const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
} }

View File

@@ -42,6 +42,7 @@ module.exports = {
"src/features/consumption/plans-action.js", "src/features/consumption/plans-action.js",
"src/features/install-base/action.js", "src/features/install-base/action.js",
"src/features/install-base/view.js", "src/features/install-base/view.js",
"src/features/opportunities/customer-table-search.js",
"src/features/service-requests/action.js", "src/features/service-requests/action.js",
"src/features/service-requests/view.js", "src/features/service-requests/view.js",
"src/features/consumption/plans-view.js", "src/features/consumption/plans-view.js",

View File

@@ -321,7 +321,9 @@ function createContentApplication(overrides = {}) {
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args), requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment environment
}); });
const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({ const installBaseView = createInstallBaseView({
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
@@ -336,6 +338,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const serviceRequestsView = createServiceRequestsView({ const serviceRequestsView = createServiceRequestsView({
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args), formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),

View File

@@ -32,6 +32,12 @@
} }
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; } .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; } .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-content,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { color: #f6f4f2; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message { color: #c9c5c1; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-message[role="alert"] { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }

View File

@@ -1,4 +1,4 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) { function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
const columns = [ const columns = [
@@ -23,6 +23,16 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
if (previous.trigger?.isConnected) previous.trigger.focus(); if (previous.trigger?.isConnected) previous.trigger.focus();
return true; return true;
} }
function formatValue(value, type) {
if (value === null || value === undefined || value === "") return "-";
if (type === "date") return formatOracleResponseDate(value);
if ((type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
}).format(Number(value));
}
return String(value).trim();
}
function createTable(items, loading = false) { function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-install-base-table"); const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items"); table.setAttribute("aria-label", "Installed base items");
@@ -49,14 +59,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
cell.append(bar); cell.append(bar);
} else { } else {
const value = item[key]; const value = item[key];
const missing = value === null || value === undefined || value === ""; const text = formatValue(value, type);
let text = missing ? "-" : String(value).trim();
if (!missing && type === "date") text = formatOracleResponseDate(value);
if (!missing && (type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
text = new Intl.NumberFormat("en-US", {
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
}).format(Number(value));
}
if (type === "status") { if (type === "status") {
const badge = element("span", "opportunities-extension-install-base-status", text); const badge = element("span", "opportunities-extension-install-base-status", text);
badge.dataset.status = text.toUpperCase(); badge.dataset.status = text.toUpperCase();
@@ -99,27 +102,44 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
content.setAttribute("aria-label", "Installed base results"); content.setAttribute("aria-label", "Installed base results");
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base..."); const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status"); status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
toolbar.append(search.input);
content.append(status, createTable([], true)); content.append(status, createTable([], true));
dialog.append(header, content); dialog.append(header, toolbar, content);
modal.append(dialog); modal.append(dialog);
overlay.append(modal); overlay.append(modal);
active = { modal, trigger }; active = { modal, trigger };
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key === "Tab") { if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
(document.activeElement === close ? content : close).focus(); const controls = [close, search.input, content];
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
} }
}); });
close.focus(); close.focus();
function renderResults() {
if (!loaded || active?.modal !== modal || overlay.hidden) return;
const items = failure ? [] : search.filter(loadedItems);
content.textContent = "";
status.textContent = failure || (loadedItems.length
? `${items.length} of ${loadedItems.length} installed base items${items.length ? "" : ". No matching records."}`
: "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
content.scrollTop = 0;
}
return { render(items, error = "") { return { render(items, error = "") {
// A closed or replaced dialog must never receive a late response. // A closed or replaced dialog must never receive a late response.
if (active?.modal !== modal || overlay.hidden) return; if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false"); modal.setAttribute("aria-busy", "false");
content.textContent = ""; loadedItems = items; failure = error; loaded = true;
status.textContent = error || (items.length ? `${items.length} installed base items` : "No installed base items found for this customer."); search.reset();
status.setAttribute("role", error ? "alert" : "status"); renderResults();
content.append(status);
if (!error && items.length) content.append(createTable(items));
} }; } };
} }
return { openInstallBaseModal, closeInstallBaseModal, createTable }; return { openInstallBaseModal, closeInstallBaseModal, createTable };

View File

@@ -0,0 +1,24 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog,
.opportunities-extension-account-plans-dialog.opportunities-extension-service-requests-dialog {
grid-template-rows: auto auto minmax(0, 1fr);
}
.opportunities-extension-customer-table-toolbar { padding: 12px 16px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-customer-table-search {
box-sizing: border-box;
width: min(420px, 100%);
min-width: 0;
height: 38px;
padding: 8px 12px;
border: 1px solid #8f8a85;
border-radius: 4px;
background: #fff;
color: #312d2a;
font: inherit;
font-size: 13px;
}
.opportunities-extension-customer-table-search::placeholder { color: #5f5a55; opacity: 1; }
.opportunities-extension-customer-table-search:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-toolbar { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search { background: #353331; color: #f6f4f2; border-color: #8f8a85; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search::placeholder { color: #c9c5c1; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-customer-table-search:focus-visible { outline-color: #59d1e5; }

View File

@@ -0,0 +1,24 @@
function createCustomerTableSearch({ environment }) {
function createSearch({ label, onSearch, getValues }) {
const input = environment.document.createElement("input");
input.type = "search";
input.className = "opportunities-extension-customer-table-search";
input.placeholder = label;
input.setAttribute("aria-label", label);
input.addEventListener("input", onSearch);
const normalize = value => String(value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
let index = new WeakMap();
return { input,
reset() { index = new WeakMap(); },
filter(items) {
const terms = normalize(input.value).trim().split(/\s+/).filter(Boolean);
if (!terms.length) return items;
return items.filter(item => {
if (!index.has(item)) index.set(item, getValues(item).map(normalize));
return terms.every(term => index.get(item).some(value => value.includes(term)));
});
}
};
}
return { createSearch };
}

View File

@@ -24,6 +24,12 @@
color: #5f5a55; background: #eceae8; color: #5f5a55; background: #eceae8;
} }
.opportunities-extension-service-request-status[data-status="OPEN"] { color: #17693a; background: #e1f1e5; } .opportunities-extension-service-request-status[data-status="OPEN"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-content,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table td { color: #f6f4f2; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-message { color: #c9c5c1; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-message[role="alert"] { color: #ffb4ab; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th { background: #353331; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th, .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table td { border-color: #484541; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table td { border-color: #484541; }

View File

@@ -1,4 +1,4 @@
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) { function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
const columns = [ const columns = [
@@ -27,6 +27,10 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
if (previous.trigger?.isConnected) previous.trigger.focus(); if (previous.trigger?.isConnected) previous.trigger.focus();
return true; return true;
} }
function formatValue(value, key) {
const text = value === null || value === undefined || value === "" ? "-" : String(value);
return text !== "-" && (key === "created" || key === "lastUpdated") ? formatOracleResponseDate(value) : text;
}
function createTable(items, loading = false) { function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-service-requests-table"); const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("aria-label", "Customer service requests"); table.setAttribute("aria-label", "Customer service requests");
@@ -52,8 +56,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
cell.append(bar); cell.append(bar);
} else { } else {
const value = getValue(item); const value = getValue(item);
let text = value === null || value === undefined || value === "" ? "-" : String(value); const text = formatValue(value, key);
if (text !== "-" && (key === "created" || key === "lastUpdated")) text = formatOracleResponseDate(value);
if (key === "status") { if (key === "status") {
const badge = element("span", "opportunities-extension-service-request-status", text); const badge = element("span", "opportunities-extension-service-request-status", text);
badge.dataset.status = text.trim().toUpperCase(); badge.dataset.status = text.trim().toUpperCase();
@@ -95,26 +98,43 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
content.setAttribute("aria-label", "Service requests results"); content.setAttribute("aria-label", "Service requests results");
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests..."); const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
status.setAttribute("role", "status"); status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
toolbar.append(search.input);
content.append(status, createTable([], true)); content.append(status, createTable([], true));
dialog.append(header, content); dialog.append(header, toolbar, content);
modal.append(dialog); modal.append(dialog);
overlay.append(modal); overlay.append(modal);
active = { modal, trigger }; active = { modal, trigger };
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key === "Tab") { if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
(document.activeElement === close ? content : close).focus(); const controls = [close, search.input, content];
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
} }
}); });
close.focus(); close.focus();
function renderResults() {
if (!loaded || active?.modal !== modal || overlay.hidden) return;
const items = failure ? [] : search.filter(loadedItems);
content.textContent = "";
status.textContent = failure || (loadedItems.length
? `${items.length} of ${loadedItems.length} service requests${items.length ? "" : ". No matching records."}`
: "No open service requests found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
content.scrollTop = 0;
}
return { render(items, error = "") { return { render(items, error = "") {
if (active?.modal !== modal || overlay.hidden) return; if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false"); modal.setAttribute("aria-busy", "false");
content.textContent = ""; loadedItems = items; failure = error; loaded = true;
status.textContent = error || (items.length ? `${items.length} service requests` : "No open service requests found for this customer."); search.reset();
status.setAttribute("role", error ? "alert" : "status"); renderResults();
content.append(status);
if (!error && items.length) content.append(createTable(items));
} }; } };
} }
return { openServiceRequestsModal, closeServiceRequestsModal, createTable }; return { openServiceRequestsModal, closeServiceRequestsModal, createTable };

View File

@@ -0,0 +1,34 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");
function luminance(hex) {
const rgb = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255)
.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722;
}
function contrast(a, b) {
const values = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (values[0] + .05) / (values[1] + .05);
}
for (const feature of ["install-base", "service-requests"]) {
test(`${feature}: explicit dark text colors cover cells, headings, counts and errors with readable contrast`, () => {
const css = fs.readFileSync(path.join(__dirname, `../src/features/${feature}/styles.css`), "utf8");
const rules = [...css.matchAll(/([^{}]+)\{([^{}]+)\}/g)];
const prefix = `.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-${feature}`;
function color(selector) {
const matches = rules.filter(([, selectors, body]) => selectors.split(",").map(value => value.trim()).includes(selector)
&& /(?:^|;)\s*color\s*:/.test(body));
assert.ok(matches.length, `Missing explicit color for ${selector}`);
return matches.at(-1)[2].match(/(?:^|;)\s*color\s*:\s*(#[a-f0-9]{6})/i)[1];
}
for (const suffix of ["-content", "-table", "-table th", "-table td", "-message", '-message[role="alert"]']) {
const foreground = color(prefix + suffix);
const background = suffix.endsWith(" th") ? "#353331" : "#292827";
assert.ok(contrast(foreground, background) >= 4.5, `${suffix} needs at least 4.5:1 contrast`);
}
assert.match(css, /color: #17693a; background: #e1f1e5/, "light status colors remain unchanged");
assert.match(css, /color: #b8e4c6; background: #214d34/, "dark status colors remain unchanged");
});
}

View File

@@ -96,7 +96,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
test("scoped debug styles are packaged after the unchanged main stylesheet", () => { test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css"] const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n"); .map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`)); assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs")); assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -100,6 +100,42 @@ function setup() {
const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } }; const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
const walk = node => [node, ...node.children.flatMap(walk)]; const walk = node => [node, ...node.children.flatMap(walk)];
test("installed base searches every displayed column and formatted values, including text entered during loading", () => {
const { app, overlay, document } = setup();
const view = app.installBaseView.openInstallBaseModal(customer);
const modal = overlay.children[0];
const input = walk(modal).find(node => node.tag === "input");
const row = { partyName: "S\u00e3o Customer", productGroupName: "Group Alpha", productName: "Database Beta",
currentSupport: 1234.56, orderNumber: "ORD-321", supportStatus: "ACTIVE", quantityType: "Processor",
quantity: 7, supportStartDate: "2026-03-12", supportEndDate: "2027-04-13", licenseValue: 9876.54, shipDate: "2020-05-14",
hiddenField: "secretword" };
input.value = "sao beta";
input.listeners.input();
assert.equal(modal.attributes["aria-busy"], "true");
view.render([row, { productName: "Other product" }]);
const rows = () => walk(modal).filter(node => node.tag === "td").length / 12;
assert.equal(rows(), 1);
for (const query of [...Object.values(row).slice(0, 12), "1,234.56", "9,876.54", "12/03/2026", "13/04/2027", "14/05/2020"]) {
input.value = String(query); input.listeners.input();
assert.equal(rows(), 1, String(query));
assert.equal(walk(modal).find(node => node.tag === "input"), input, "input is not replaced while typing");
}
input.value = "secretword"; input.listeners.input();
assert.equal(rows(), 0);
assert.ok(walk(modal).some(node => node.textContent.includes("No matching records.")));
input.value = " "; input.listeners.input();
assert.equal(rows(), 2);
input.focus();
modal.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() {} });
assert.equal(document.activeElement.tag, "button");
view.render([], "Synthetic failure");
input.value = "other"; input.listeners.input();
assert.ok(walk(modal).some(node => node.textContent === "Synthetic failure"));
app.installBaseView.closeInstallBaseModal();
app.installBaseView.openInstallBaseModal(customer).render([row]);
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 12, "reopening starts without an invisible filter");
});
test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => { test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => {
const { app, overlay, document } = setup(); const { app, overlay, document } = setup();
const first = app.installBaseView.openInstallBaseModal(customer); const first = app.installBaseView.openInstallBaseModal(customer);
@@ -122,6 +158,8 @@ test("modal shows skeleton, renders only requested fields safely and ignores clo
assert.equal(cells[10].textContent, "0.00"); assert.equal(cells[10].textContent, "0.00");
assert.ok(cells[8].textContent.includes("2026")); assert.ok(cells[8].textContent.includes("2026"));
current.listeners.keydown({ key: "Tab", preventDefault() {} }); current.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.type, "search");
current.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.attributes.role, "region"); assert.equal(document.activeElement.attributes.role, "region");
assert.equal(app.installBaseView.closeInstallBaseModal(), true); assert.equal(app.installBaseView.closeInstallBaseModal(), true);
second.render([], "Late error"); second.render([], "Late error");

View File

@@ -158,6 +158,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.ok(remaining > 0); assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset"); assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
} }
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css"].map(read).join("\n"); const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`)); assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
}); });

View File

@@ -143,6 +143,35 @@ function setup() {
} }
const customer = { CustomerAccount: { PartyNumber: "customer-test", PartyUniqueName: "Synthetic customer" } }; 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", () => {
const { app, overlay } = setup();
const view = app.serviceRequestsView.openServiceRequestsModal(customer);
const modal = overlay.children[0];
const input = walk(modal).find(node => node.tag === "input");
const row = { customer: { name: "S\u00e3o Customer", registryId: "REG-123" }, id: "SR-456", severity: "2", status: "Open",
subStatus: "Work In Progress", component: { productName: "Database Alpha" }, os: "Linux", csi: "CSI-789",
created: "2026-03-12", lastUpdated: "2026-04-13", hiddenField: "secretword" };
input.value = "SAO linux"; input.listeners.input();
view.render([row, { id: "Other" }]);
const rows = () => walk(modal).filter(node => node.tag === "td").length / 11;
assert.equal(rows(), 1);
for (const query of ["sao", "REG-123", "SR-456", "2", "open", "progress", "alpha", "linux", "CSI-789", "12/03/2026", "13/04/2026", "2026-03-12"]) {
input.value = query; input.listeners.input();
assert.equal(rows(), 1, query);
}
input.value = "secretword"; input.listeners.input();
assert.equal(rows(), 0);
assert.ok(walk(modal).some(node => node.textContent.includes("No matching records.")));
input.value = ""; input.listeners.input();
assert.equal(rows(), 2);
view.render([], "Synthetic failure");
input.value = "anything"; input.listeners.input();
assert.ok(walk(modal).some(node => node.textContent === "Synthetic failure"));
app.serviceRequestsView.closeServiceRequestsModal();
app.serviceRequestsView.openServiceRequestsModal(customer).render([row]);
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 11);
});
test("skeleton, exact field mapping, safe text, dates, badge, empty/error and focus states", () => { test("skeleton, exact field mapping, safe text, dates, badge, empty/error and focus states", () => {
const { app, overlay, document } = setup(); const { app, overlay, document } = setup();
const trigger = new Element("button", document); const trigger = new Element("button", document);
@@ -169,6 +198,8 @@ test("skeleton, exact field mapping, safe text, dates, badge, empty/error and fo
assert.equal(cells[9].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-03-04")); assert.equal(cells[9].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-03-04"));
assert.equal(cells[10].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-09-17")); assert.equal(cells[10].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-09-17"));
modal.listeners.keydown({ key: "Tab", preventDefault() {} }); modal.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.type, "search");
modal.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.attributes.role, "region"); assert.equal(document.activeElement.attributes.role, "region");
view.render([]); view.render([]);
assert.ok(walk(modal).some(node => node.textContent === "No open service requests found for this customer.")); assert.ok(walk(modal).some(node => node.textContent === "No open service requests found for this customer."));