Adjusted typography, styles, and other visual elements to ensure consistent layouts

This commit is contained in:
2026-10-26 19:14:18 -03:00
parent 3229ce9c41
commit 7606643ff4
20 changed files with 498 additions and 30 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -25,7 +25,7 @@ function composeScript(target, { bootstrap = true } = {}) {
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
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", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css")
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}

View File

@@ -111,6 +111,15 @@ const os = require("node:os");
await page.evaluate(() => { window.fixtureOpportunitiesDelay = 0; });
assert.equal(await page.evaluate(() => window.fixtureContactQueries.length), queriesBeforeOpportunities, "Opportunity lookup must reuse the person's PartyNumber without searching contacts");
assert.equal(await contactOpportunities.locator("tbody tr").count(), 6);
const presentation = await contactOpportunities.locator("table").evaluate(table => ({
marker: table.getAttribute("data-table-layout"),
textHeader: getComputedStyle(table.querySelector("th")).textAlign,
moneyHeader: getComputedStyle(table.querySelector('th[data-align="money"]')).textAlign,
moneyCell: getComputedStyle(table.querySelector('td[data-align="money"]')).textAlign,
nowrap: getComputedStyle(table.querySelector(".opportunities-extension-sort-label")).whiteSpace
}));
assert.deepEqual(presentation,{marker:"standard",textHeader:"left",moneyHeader:"right",moneyCell:"right",nowrap:"nowrap"});
await page.screenshot({path:path.join(output,"shared-table.png")});
assert.equal(await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("target"), "_blank");
assert.ok((await contactOpportunities.getByRole("link", {name:"Contact opportunity 0",exact:true}).getAttribute("href")).endsWith("?puid=CONTACT0"));
assert.deepEqual((await page.evaluate(()=>window.fixtureOpportunitiesByContact)).map(url=>new URL(url).searchParams.get("offset")), ["0","5"]);

View File

@@ -404,6 +404,7 @@ function createContentModal({
const opportunitiesTable = document.createElement("table");
opportunitiesTable.id = OPPORTUNITIES_TABLE_ID;
opportunitiesTable.className = "opportunities-extension-table";
opportunitiesTable.setAttribute("data-table-layout", "standard");
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);

View File

@@ -0,0 +1,78 @@
/* Modal titles follow the customer Service Requests heading. Keep section and
calendar headings independent from dialog typography. */
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header
) {
padding: 16px;
gap: 16px;
align-items: flex-start;
}
/* These headers live in panels that already supply the outer inset. */
.opportunities-extension-modal :is(.oe-org-dialog.oe-contact-dialog,
.oe-org-confirm-panel, .opportunities-extension-projection-confirm,
.opportunities-extension-debug-panel) { padding: 16px; }
.opportunities-extension-modal :is(.oe-org-editor-header,
.opportunities-extension-debug-panel-header) {
padding: 0;
gap: 16px;
align-items: flex-start;
}
.opportunities-extension-modal :is(
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header
) p {
margin: 5px 0 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1.35;
letter-spacing: normal;
overflow-wrap: anywhere;
}
/* Search controls share the 16px inset of their table frames. */
.opportunities-extension-modal :is(.opportunities-extension-customer-table-toolbar,
.opportunities-extension-ratecard-toolbar,
.opportunities-extension-ratecard-plan-details) { padding-inline: 16px; }
.opportunities-extension-modal .opportunities-extension-customer-table-toolbar {
padding-block: 12px;
}
.opportunities-extension-modal .opportunities-extension-customer-table-search {
width: min(420px, 100%);
height: 38px;
padding: 8px 12px;
font-size: 13px;
line-height: 20px;
}
.opportunities-extension-modal :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal .oe-org-confirm-panel h3 {
margin: 0;
font-family: "Oracle Sans", Arial, Helvetica, sans-serif;
font-size: 20px;
font-weight: 700;
line-height: 24px;
letter-spacing: normal;
color: #312d2a;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-modal[data-theme="dark"] :is(
.opportunities-extension-header,
.opportunities-extension-account-plans-header,
.opportunities-extension-preferences-header,
.opportunities-extension-debug-panel-header,
.oe-org-editor-header
) :is(h1, h2, h3),
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-confirm h2,
.opportunities-extension-modal[data-theme="dark"] .oe-org-confirm-panel h3 {
color: #f6f4f2;
}

View File

@@ -0,0 +1,106 @@
/* Shared data-table presentation. Calendar grids are intentionally not data tables. */
.opportunities-extension-modal :is(
.opportunities-extension-table-surface,
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport,
.opportunities-extension-projection-viewport,
.oe-time-grid,
.oe-contact-opportunities-table
) {
box-sizing: border-box;
min-width: 0;
border: 1px solid var(--oe-table-heading-border);
border-radius: 4px;
overflow: auto;
}
/* Unpadded dialogs need an inset on the scrolling frame itself. */
.opportunities-extension-modal :is(
.opportunities-extension-install-base-content,
.opportunities-extension-service-requests-content,
.opportunities-extension-ratecard-viewport
) { margin: 16px; }
/* Other frames already sit inside a padded body: keep a single gutter. */
.opportunities-extension-modal :is(
.opportunities-extension-body,
.opportunities-extension-projection-body,
.opportunities-extension-products-detail,
.oe-time-body,
.oe-org-dialog.oe-contact-opportunities
) { padding: 16px; }
.opportunities-extension-modal table.opportunities-extension-products-table {
border: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal {
--oe-table-heading: #faf9f8;
--oe-table-text: #312d2a;
--oe-table-border: #ebe8e5;
--oe-table-heading-border: #b8b2ad;
--oe-table-hover: #f5f4f2;
}
.opportunities-extension-modal[data-theme="dark"] {
--oe-table-heading: #333130;
--oe-table-text: #f6f4f2;
--oe-table-border: #3f3c39;
--oe-table-heading-border: #4e4a46;
--oe-table-hover: #35322f;
}
.opportunities-extension-modal table[data-table-layout="standard"]:not(.fc-scrollgrid) {
width: 100%; border-collapse: collapse; border-spacing: 0; table-layout: auto;
color: var(--oe-table-text); font-size: 12px; line-height: 1.35;
}
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th {
padding: 10px 12px; height: 44px; vertical-align: middle; text-align: left;
white-space: nowrap; overflow-wrap: normal; word-break: normal;
font-size: 12px; font-weight: 700; color: var(--oe-table-text);
background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-heading-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > td {
padding: 10px 12px; vertical-align: middle; text-align: left;
border-bottom: 1px solid var(--oe-table-border);
}
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr:hover { background: var(--oe-table-hover); }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort {
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
width: 100%; min-width: max-content; min-height: 28px; padding: 4px 18px 4px 0;
border: 0; border-radius: 0; background: transparent; color: inherit;
text-align: left; white-space: nowrap; overflow-wrap: normal; font-weight: 700;
}
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-sort-label { white-space: nowrap; overflow-wrap: normal; word-break: normal; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button::after { position: absolute; right: 0; left: auto; font-size: 12px; }
.opportunities-extension-modal table[data-table-layout="standard"] .opportunities-extension-ratecard-sort-icon { margin-left: auto; }
/* Currency columns keep their heading and value on the same right edge. */
.opportunities-extension-modal table[data-table-layout="standard"] :is(th, td)[data-align="money"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button,
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort { justify-content: flex-end; text-align: right; padding: 4px 0 4px 18px; }
.opportunities-extension-modal table[data-table-layout="standard"] > thead > tr > th[data-align="money"] .opportunities-extension-sort-button::after { left: 0; right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* Preserve room for Overview badges and the compact daily entry controls. */
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 38px; vertical-align: top; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
min-width: 0;
table-layout: fixed;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) {
padding-inline: 6px;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th,
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
white-space: normal;
overflow-wrap: normal;
word-break: normal;
hyphens: none;
min-width: 0;
}
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-ratecard-sort {
padding: 4px 0;
gap: 4px;
}
.opportunities-extension-modal .opportunities-extension-ratecard-sort > span:first-child { min-width: 0; }
.opportunities-extension-modal .opportunities-extension-ratecard-sort-icon { flex: 0 0 12px; }

View File

@@ -43,7 +43,6 @@
width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: collapse; font-size: 11px;
}
.opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }

View File

@@ -2,7 +2,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const { document } = environment;
let active = null;
const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
@@ -36,6 +36,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
const header = element("tr");

View File

@@ -114,6 +114,7 @@ function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelec
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
const scroll = node("div", "oe-time-grid"), table = node("table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
const add = button("Add", "add", () => addRow("Service"), true);

View File

@@ -76,6 +76,7 @@ function createOpportunitiesTable({
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col";
header.setAttribute("data-column", column.key);
if (column.key === "revenue") header.setAttribute("data-align", "money");
if (column.sortable === false) {
const label = document.createElement("span");
@@ -117,6 +118,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_COLUMNS.forEach(column => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "revenue") cell.setAttribute("data-align", "money");
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-text";
cell.append(bar);
@@ -149,6 +151,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "revenue") cell.setAttribute("data-align", "money");
if (column.key === "stage") {
cell.append(createStageBadge(column.display(item)));

View File

@@ -136,6 +136,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
header.append(title, button("Close opportunities", "close", finish));
const status = el("p", "oe-org-status"), surface = el("div", "oe-contact-opportunities-table"), table = el("table"), head = el("thead"), row = el("tr"), body = el("tbody");
status.setAttribute("role", "status");
table.setAttribute("data-table-layout", "standard");
const columns = [
{ key: "OptyNumber", label: "OptyNumber" }, { key: "WinProb", label: "Win Probability", type: "number" },
{ key: "Name", label: "Name" }, { key: "CurrencyCode", label: "CurrencyCode" },
@@ -143,7 +144,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
];
const sorting = tableSorting.create("contact-opportunities", columns);
let allItems = [], loading = false;
for (const column of columns) { const th = el("th"); th.scope = "col"; sorting.header(th, column.key, column.label, renderRows); row.append(th); }
for (const column of columns) { const th = el("th"); th.scope = "col"; if (column.key === "Revenue") th.setAttribute("data-align", "money"); sorting.header(th, column.key, column.label, renderRows); row.append(th); }
head.append(row); table.append(head, body); surface.append(table);
const footer = el("footer", "oe-org-form-actions");
const retry = button("Retry", "plus", () => load(), true);
@@ -167,6 +168,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
const row = el("tr"); row.setAttribute("aria-hidden", "true");
for (const column of columns) {
const cell = el("td"), bar = el("span", "oe-contact-skeleton");
if (column.key === "Revenue") cell.setAttribute("data-align", "money");
bar.style.width = column.key === "Name" ? "90%" : column.key === "WinProb" || column.key === "StatusCode" ? "45%" : "70%";
cell.append(bar); row.append(cell);
}
@@ -177,7 +179,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
for (const item of sorting.select(allItems)) {
const row = el("tr");
for (const key of ["OptyNumber", "WinProb", "Name", "CurrencyCode", "Revenue", "EffectiveDate", "StatusCode"]) {
const cell = el("td"); let value = item[key] ?? "—";
const cell = el("td"); if (key === "Revenue") cell.setAttribute("data-align", "money"); let value = item[key] ?? "—";
if (key === "Name" && item.OptyNumber) {
const link = el("a", "", String(value)); link.href = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/cx-sales/application/container/opportunities/opportunities-detail?puid=" + encodeURIComponent(item.OptyNumber); link.target = "_blank"; link.rel = "noopener noreferrer"; cell.append(link);
} else {

View File

@@ -101,6 +101,7 @@ function createProductsDetails({
} else {
const table = document.createElement("table");
table.className = "opportunities-extension-products-table";
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", `Products for opportunity ${optyNumber}`);
const columns = [
@@ -123,6 +124,7 @@ function createProductsDetails({
columns.forEach((column) => {
const header = document.createElement("th");
header.scope = "col";
if (column.label === "Amount") header.setAttribute("data-align", "money");
header.textContent = column.label;
headerRow.append(header);
});
@@ -133,6 +135,7 @@ function createProductsDetails({
const productRow = document.createElement("tr");
columns.forEach((column) => {
const productCell = document.createElement("td");
if (column.label === "Amount") productCell.setAttribute("data-align", "money");
if (column.render) {
productCell.className = "opportunities-extension-projection-cell";
productCell.append(column.render(product));

View File

@@ -94,6 +94,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Monthly consumption comparison");
const table = node("table", "opportunities-extension-projection-table");
table.setAttribute("data-table-layout", "standard");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
const sorting = tableSorting.create("consumption-projection", [
@@ -103,7 +104,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
const rowNodes = new Map();
const renderRows = () => tbody.replaceChildren(...sorting.select(result.rows).map(item => rowNodes.get(item)));
for (const [key, label] of [["month", "Month"], ["actual", "Forecast"], ["calculated", "Calculated"], ["difference", "Difference"]]) {
const th = node("th"); th.scope = "col";
const th = node("th"); th.scope = "col"; if (key !== "month") th.setAttribute("data-align", "money");
sorting.header(th, key, label, renderRows); headRow.append(th);
}
head.append(headRow);
@@ -114,7 +115,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
month.scope = "row";
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
for (const value of [item.actual, item.calculated, item.difference]) { const cell = node("td", "", value === null ? "-" : formatAmount(value, currency)); cell.setAttribute("data-align", "money"); tr.append(cell); }
rowNodes.set(item, tr);
}
renderRows();

View File

@@ -139,3 +139,8 @@
.opportunities-extension-ratecard-overlay, .opportunities-extension-ratecard-drawer, .opportunities-extension-ratecard-skeleton,
.opportunities-extension-ratecard-overlay[data-closing="true"], .opportunities-extension-ratecard-overlay[data-closing="true"] .opportunities-extension-ratecard-drawer { animation: none; transition: none; }
}
.opportunities-extension-ratecard-toolbar { flex-wrap: wrap; }
.opportunities-extension-ratecard-order-filter { position: relative; min-width: 0; }
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-trigger { min-height: 38px; padding-block: 8px; font-size: 13px; }
.opportunities-extension-ratecard-order-filter .opportunities-extension-customer-filter-panel { width: min(320px, calc(100vw - 80px)); max-height: 45vh; overflow: auto; }

View File

@@ -65,12 +65,80 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
search.setAttribute("aria-label", "Search rate card");
const count = element("span", "count", "Loading rate card...");
count.setAttribute("role", "status");
toolbar.append(search, count);
const orderFilter = element("div", "order-filter");
const filterElement = (tag, suffix, text) => {
const node = element(tag, null, text);
node.className = `opportunities-extension-customer-filter-${suffix}`;
return node;
};
const orderButton = filterElement("button", "trigger", "All order numbers");
orderButton.type = "button";
orderButton.disabled = true;
orderButton.setAttribute("aria-label", "Filter by Order Number");
orderButton.setAttribute("aria-expanded", "false");
const orderPanel = filterElement("section", "panel");
orderPanel.id = "opportunities-extension-ratecard-orders";
orderButton.setAttribute("aria-controls", orderPanel.id);
orderPanel.hidden = true;
const orderSearch = filterElement("input", "search");
orderSearch.type = "search";
orderSearch.placeholder = "Search order numbers";
orderSearch.setAttribute("aria-label", "Search order numbers");
const allLabel = filterElement("label", "select-all");
const allOrders = element("input");
allOrders.type = "checkbox";
allLabel.append(allOrders, element("span", null, "Select all"));
const orderList = filterElement("div", "list");
orderPanel.append(orderSearch, allLabel, orderList);
orderFilter.append(orderButton, orderPanel);
toolbar.append(search, orderFilter, count);
let orders = [], selectedOrders = new Set();
const orderKey = item => String(item.orderNumber ?? "").trim();
function closeOrders() {
orderPanel.hidden = true;
orderButton.setAttribute("aria-expanded", "false");
}
function updateOrders() {
const all = orders.length > 0 && selectedOrders.size === orders.length;
allOrders.checked = all;
allOrders.indeterminate = selectedOrders.size > 0 && !all;
orderButton.textContent = all ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
orderList.textContent = "";
const query = orderSearch.value.trim().toLowerCase();
orders.filter(value => (value || "Not provided").toLowerCase().includes(query)).forEach(value => {
const label = filterElement("label", "option");
const checkbox = element("input");
checkbox.type = "checkbox";
checkbox.checked = selectedOrders.has(value);
checkbox.addEventListener("change", () => {
if (checkbox.checked) selectedOrders.add(value); else selectedOrders.delete(value);
allOrders.checked = selectedOrders.size === orders.length;
allOrders.indeterminate = selectedOrders.size > 0 && !allOrders.checked;
orderButton.textContent = allOrders.checked ? "All order numbers" : `${selectedOrders.size} order numbers selected`;
renderRows();
});
label.append(checkbox, element("span", null, value || "Not provided"));
orderList.append(label);
});
}
orderButton.addEventListener("click", () => {
orderPanel.hidden = !orderPanel.hidden;
orderButton.setAttribute("aria-expanded", String(!orderPanel.hidden));
if (!orderPanel.hidden) orderSearch.focus();
});
orderSearch.addEventListener("input", updateOrders);
allOrders.addEventListener("change", () => {
selectedOrders = new Set(allOrders.checked ? orders : []);
updateOrders();
renderRows();
});
root.addEventListener("click", event => { if (!orderFilter.contains(event.target)) closeOrders(); });
const viewport = element("div", "viewport");
viewport.tabIndex = 0;
viewport.setAttribute("role", "region");
viewport.setAttribute("aria-label", "Rate card items");
const table = element("table", "table");
table.setAttribute("data-table-layout", "standard");
const caption = element("caption", null, "Rate card items");
caption.className = "opportunities-extension-visually-hidden";
const colgroup = element("colgroup");
@@ -90,6 +158,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
colgroup.append(col);
const th = element("th");
th.scope = "col";
if (column.type === "price") th.setAttribute("data-align", "money");
th.setAttribute("aria-sort", "none");
const sort = element("button", "sort");
sort.type = "button";
@@ -139,7 +208,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
function renderRows() {
if (!alive()) return;
body.textContent = "";
const visible = selectItems(current.items, search.value, current.sortKey, current.direction);
const visible = selectItems(current.items.filter(item => selectedOrders.has(orderKey(item))), search.value, current.sortKey, current.direction);
count.textContent = `${visible.length} of ${current.items.length} items`;
if (!visible.length) {
showNotice(current.items.length ? "No matching rate card items." : "No rate card items found for this plan.");
@@ -148,6 +217,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const row = element("tr");
columns.forEach(column => {
const cell = element("td", null, formatValue(item, column));
if (column.type === "price") cell.setAttribute("data-align", "money");
if (["price", "number", "percent"].includes(column.type)) cell.className = "opportunities-extension-ratecard-numeric";
row.append(cell);
});
@@ -172,9 +242,12 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!orderPanel.hidden) { closeOrders(); orderButton.focus(); return; }
closeRatecardDrawer();
} else if (event.key === "Tab") {
const controls = [close, search, viewport, ...headRow.querySelectorAll("button")];
const controls = [close, search, ...(!orderButton.disabled ? [orderButton] : []),
...(!orderPanel.hidden ? [orderSearch, allOrders, ...orderList.querySelectorAll("input")] : []),
viewport, ...headRow.querySelectorAll("button")];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
@@ -196,6 +269,10 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
showItems(items) {
if (!alive()) return;
current.items = items;
orders = [...new Set(items.map(orderKey))].sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
selectedOrders = new Set(orders);
orderButton.disabled = !orders.length;
updateOrders();
current.status = "ready";
panel.setAttribute("aria-busy", "false");
renderRows();

View File

@@ -34,6 +34,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const sortColumns = columns.map(([key, , value]) => ({ key, value, type: ["created", "lastUpdated"].includes(key) ? "date" : "text" }));
function createTable(items, loading = false, sorting = tableSorting.create("service-requests", sortColumns), onSort) {
const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("data-table-layout", "standard");
table.setAttribute("aria-label", "Customer service requests");
const group = element("colgroup");
const head = element("thead");

View File

@@ -31,6 +31,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
const source = read(file);
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(|\bimportScripts\s*\(|\brequire\s*\(|^\s*(?:import|export)\s/m, file);
if (file.endsWith("/application.js") || file.endsWith("/main.js")) continue;
if (file === "src/features/org-chart/calling-codes.js") { assert.match(source, /const orgChartCallingCodes\s*=/); continue; }
const declaration = source.match(/^function (\w+)\(/m)[1];
for (const factory of factories.filter(name => name !== declaration)) {
assert.doesNotMatch(source, new RegExp(`\\b${factory}\\s*\\(`), `${file} must receive dependencies, not construct ${factory}`);

View File

@@ -308,7 +308,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css")
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -319,6 +319,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 114, "four 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", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/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", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});