Repositioned the opportunity and customer control buttons

This commit is contained in:
2026-09-22 12:07:12 -03:00
parent eed4189a07
commit 8d73954362
9 changed files with 359 additions and 62 deletions

View File

@@ -31,11 +31,22 @@ Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css` at build time. There is no `src/features/debug/styles.css` and `src/features/opportunities/styles.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency. runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance ## Tests and Maintenance
The opportunities table places product counts below Name and the customer
consumption action below Customer, without a separate Actions column. Compact
columns retain their values; Name and Customer share the remaining width while
Owner is fixed at 132px. Both text cells reserve at least two text lines, and
their controls stay anchored 10px above the row bottom, including while products
load. Longer text can grow the row without overlapping controls. Horizontal
scrolling is reserved for table containers below
1120px. The scoped table stylesheet does not change product-table column widths
or the existing light/dark colors.
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start. Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response, waiting for headers or the response body, and updates with HTTP status, response,

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"] const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.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

@@ -9,7 +9,6 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode }, { key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability }, { key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c }, { key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }, { key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
{ key: "actions", label: "Actions", value: () => "", display: () => "", sortable: false }
]; ];
} }

View File

@@ -0,0 +1,113 @@
/* Keep layout rules on the outer table, never on expanded product tables. */
.opportunities-extension-table[data-opportunities-layout="inline-actions"] {
width: 100%;
min-width: 1120px;
table-layout: fixed;
}
/* Fixed table layout shares leftover width only between the two auto columns. */
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 132px; }
.opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 106px; }
.opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 124px; }
.opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 100px; }
.opportunities-extension-table > thead > tr > th[data-column="status"] { width: 114px; }
.opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 110px; }
.opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 94px; }
.opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 136px; }
.opportunities-extension-table > tbody > tr > td[data-column] {
padding: 10px 8px;
overflow: visible;
text-overflow: clip;
white-space: normal;
font-variant-numeric: tabular-nums;
}
.opportunities-extension-table > tbody > tr > td[data-column="name"],
.opportunities-extension-table > tbody > tr > td[data-column="customer"] {
position: relative;
padding-right: 16px;
padding-left: 12px;
padding-bottom: 38px;
vertical-align: top;
}
.opportunities-extension-table > tbody > tr > td[data-column="owner"] {
padding-right: 16px;
}
.opportunities-extension-table > tbody > tr > td[data-column="closeDate"],
.opportunities-extension-table > tbody > tr > td[data-column="lastUpdateDate"],
.opportunities-extension-table > tbody > tr > td[data-column="status"],
.opportunities-extension-table > tbody > tr > td[data-column="stage"],
.opportunities-extension-table > tbody > tr > td[data-column="winProbability"] {
white-space: nowrap;
}
.opportunities-extension-cell-content {
display: block;
min-width: 0;
min-height: 2.7em;
line-height: 1.35;
}
.opportunities-extension-cell-content > :first-child {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-cell-actions {
position: absolute;
left: 12px;
right: 16px;
bottom: 10px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
height: 24px;
}
.opportunities-extension-cell-actions .opportunities-extension-action-button {
width: 24px;
height: 24px;
padding: 2px;
}
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
width: 18px;
height: 18px;
}
.opportunities-extension-cell-actions button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button {
display: grid;
grid-template-columns: minmax(0, 1fr) 12px;
gap: 6px;
padding: 10px 8px;
align-items: center;
}
.opportunities-extension-table > thead > tr > th[data-column="name"] .opportunities-extension-sort-button,
.opportunities-extension-table > thead > tr > th[data-column="customer"] .opportunities-extension-sort-button {
padding-left: 12px;
padding-right: 16px;
}
.opportunities-extension-sort-label {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button::after {
position: static;
justify-self: end;
}

View File

@@ -48,6 +48,7 @@ function createOpportunitiesTable({
} }
table.textContent = ""; table.textContent = "";
table.setAttribute("data-opportunities-layout", "inline-actions");
table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false"); table.setAttribute("aria-busy", opportunitiesTableState.opportunitiesTableState.status === "loading" ? "true" : "false");
const caption = document.createElement("caption"); const caption = document.createElement("caption");
@@ -61,6 +62,7 @@ function createOpportunitiesTable({
const header = document.createElement("th"); const header = document.createElement("th");
const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key; const isSorted = opportunitiesTableState.opportunitiesTableState.sortKey === column.key;
header.scope = "col"; header.scope = "col";
header.setAttribute("data-column", column.key);
if (column.sortable === false) { if (column.sortable === false) {
const label = document.createElement("span"); const label = document.createElement("span");
@@ -75,7 +77,10 @@ function createOpportunitiesTable({
sortButton.className = "opportunities-extension-sort-button"; sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none"); sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`); sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
sortButton.textContent = column.label; const sortLabel = document.createElement("span");
sortLabel.className = "opportunities-extension-sort-label";
sortLabel.textContent = column.label;
sortButton.append(sortLabel);
sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key)); sortButton.addEventListener("click", () => sortOpportunitiesBy(column.key));
header.append(sortButton); header.append(sortButton);
} }
@@ -103,32 +108,26 @@ function createOpportunitiesTable({
OPPORTUNITIES_COLUMNS.forEach((column) => { OPPORTUNITIES_COLUMNS.forEach((column) => {
const cell = document.createElement("td"); const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
if (column.key === "stage") { if (column.key === "stage") {
cell.append(createStageBadge(column.display(item))); cell.append(createStageBadge(column.display(item)));
} else if (column.key === "status") { } else if (column.key === "status") {
cell.append(createStatusBadge(column.display(item))); cell.append(createStatusBadge(column.display(item)));
} else if (column.key === "actions") {
const actionsContent = document.createElement("span");
actionsContent.className = "opportunities-extension-actions-content";
const productsResult = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) {
actionsContent.append(createProductsCountButton(item.OptyNumber, productsResult));
}
actionsContent.append(createCloudConsumptionButton(item));
cell.append(actionsContent);
} else if (column.key === "name" || column.key === "optyNumber") { } else if (column.key === "name" || column.key === "optyNumber") {
const opportunityLink = createDetailLink( const opportunityLink = createDetailLink(
column.display(item), column.display(item),
item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : "" item.OptyNumber ? `${OPPORTUNITY_DETAIL_URL}${encodeURIComponent(item.OptyNumber)}` : ""
); );
if (column.key === "name" && item.OptyNumber) { if (column.key === "name") {
const nameCellContent = document.createElement("span"); const nameCellContent = document.createElement("span");
nameCellContent.className = "opportunities-extension-name-content"; nameCellContent.className = "opportunities-extension-cell-content";
nameCellContent.append(opportunityLink); const productActions = document.createElement("span");
productActions.className = "opportunities-extension-cell-actions";
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
if (productsResult) productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent); cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) { } else if (column.key === "optyNumber" && item.OptyNumber) {
const opportunityCellContent = document.createElement("span"); const opportunityCellContent = document.createElement("span");
@@ -139,12 +138,19 @@ function createOpportunitiesTable({
cell.append(opportunityLink); cell.append(opportunityLink);
} }
} else if (column.key === "customer") { } else if (column.key === "customer") {
cell.append(createDetailLink( const customerCellContent = document.createElement("span");
customerCellContent.className = "opportunities-extension-cell-content";
const customerLink = createDetailLink(
column.display(item), column.display(item),
getNestedValue(item, ["CustomerAccount", "PartyId"]) getNestedValue(item, ["CustomerAccount", "PartyId"])
? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}` ? `${ACCOUNT_DETAIL_URL}${encodeURIComponent(getNestedValue(item, ["CustomerAccount", "PartyId"]))}`
: "" : ""
)); );
const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item));
customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent);
} else { } else {
cell.textContent = displayOpportunityValue(column, item); cell.textContent = displayOpportunityValue(column, item);
} }

View File

@@ -93,7 +93,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"] const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.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

@@ -0,0 +1,158 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication, composeScript } = require("./support/application.cjs");
class Element {
constructor(tag) {
this.tag = tag;
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.classes = new Set();
this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) };
this.text = "";
}
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, handler) { this.listeners[key] = handler; }
click() { if (!this.disabled) this.listeners.click({ preventDefault() {}, stopPropagation() {} }); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
const hasClass = (node, name) => (node.className || "").split(" ").includes(name);
const findClass = (node, name) => walk(node).find(child => hasClass(child, name));
const synthetic = (number = "TEST-1", name = "Synthetic opportunity") => ({
Name: name, OptyNumber: number, CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "party/id", PartyNumber: "reg-123" },
Owner: { PersonFirstName: "Test", PersonLastName: "Owner" }, PrimaryRevenue: { RevnAmount: "123456.78", RevnAmountCurcyCode: "USD", WinProb: 60 },
EffectiveDate: "2026-10-23", LastUpdateDate: "2026-09-22T13:15:00Z", StatusCode: "OPEN", ForecastGroup_c: "FORECAST"
});
function setup(items = [synthetic()]) {
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 summaries = [];
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
const rows = () => table.children.find(child => child.tag === "tbody").children;
const cell = (key, index = 0) => rows()[index].children.find(child => child.attributes["data-column"] === key);
const headers = () => table.children.find(child => child.tag === "thead").children[0].children;
return { app, table, rows, cell, headers, summaries };
}
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" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(h.headers().length, 10);
assert.equal(h.headers().some(header => header.attributes["data-column"] === "actions"), false);
const name = h.cell("name").children[0];
const customer = h.cell("customer").children[0];
assert.equal(name.children[0].tag, "a");
assert.equal(name.children[0].textContent, "Synthetic opportunity");
assert.ok(name.children[0].href.endsWith("TEST-1"));
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
assert.equal(name.children[1].children[0].textContent, "1");
assert.equal(customer.children[0].textContent, "Synthetic customer");
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
});
test("products remain expandable with AI summary and a ten-column detail row after sorting", () => {
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
h.app.productsContentState.opptyProductsByNumber.set("B", { items: [{ ProdGroupName: "Cloud", RevnAmount: "10" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.deepEqual(h.summaries, ["B"]);
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(h.rows()[1].children[0].colSpan, 10);
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
h.headers()[0].children[0].click();
assert.equal(h.cell("name").children[0].children[0].textContent, "Alpha");
assert.equal(h.cell("name", 1).children[0].children[0].textContent, "Zulu");
assert.equal(h.rows()[2].children[0].colSpan, 10);
findClass(h.cell("name", 1), "opportunities-extension-products-count").click();
assert.equal(h.rows().length, 2);
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
});
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
const h = setup();
const opens = [];
const requests = [];
h.app.consumptionPlansView.openAccountPlansModal = (...args) => opens.push(args);
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ synthetic: true });
h.app.platformContentMessages.sendRuntimeMessage = async message => { requests.push(message); return { ok: true, plans: [] }; };
findClass(h.cell("customer"), "opportunities-extension-action-button").click();
assert.equal(opens[0][2], "loading");
assert.equal(findClass(h.cell("customer"), "opportunities-extension-action-button").disabled, true);
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 1);
assert.equal(requests[0].partyNumber, "reg-123");
assert.equal(requests[0].type, "opportunitiesExtension.requestAccountPlans");
assert.equal(opens.at(-1)[2], "ready");
assert.equal(findClass(h.cell("customer"), "opportunities-extension-action-button").disabled, false);
});
test("pending, zero-count, failed and missing-data rows preserve their layout and full values", () => {
const h = setup([{ ...synthetic(), StatusCode: "WON_PENDING" }]);
const actions = () => h.cell("name").children[0].children[1];
assert.equal(actions().children.length, 0, "space remains reserved while products load");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [], error: "Synthetic failure" });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(actions().children[0].textContent, "0");
assert.equal(actions().children[0].title, "Synthetic failure");
assert.equal(h.cell("status").children[0].textContent, "WON_PENDING");
assert.equal(h.cell("revenue").textContent, "$123,456.78");
assert.ok(h.cell("lastUpdateDate").textContent.includes("2026"));
const missing = { Name: "No identifiers", StatusCode: "OPEN", ForecastGroup_c: "FORECAST" };
h.app.opportunitiesFilters.getSortedOpportunities = () => [missing];
h.app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items: [missing] });
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count"), undefined);
assert.equal(h.cell("customer").children[0].children[0].textContent, "-");
});
test("loading, empty and error messages span exactly the visible columns", () => {
const h = setup();
for (const status of ["loading", "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);
}
});
test("table styles reserve compact widths, prioritize Name/Customer and keep nested tables untouched", () => {
const read = file => fs.readFileSync(path.join(__dirname, "..", file), "utf8");
const css = read("src/features/opportunities/styles.css");
const fixed = [...css.matchAll(/th\[data-column="\w+"\] \{ width: (\d+)px;/g)].reduce((sum, match) => sum + Number(match[1]), 0);
assert.equal(fixed, 916);
const flexible = [...css.matchAll(/th\[data-column="(\w+)"\] \{ width: auto;/g)].map(match => match[1]);
assert.deepEqual(flexible, ["name", "customer"]);
assert.match(css, /th\[data-column="owner"\] \{ width: 132px;/);
assert.doesNotMatch(css, /width: (?:calc|clamp)\(/);
assert.match(css, /min-width: 1120px/);
assert.match(css, /min-height: 2\.7em/);
assert.match(css, /padding-bottom: 38px/);
const actions = css.match(/\.opportunities-extension-cell-actions \{([^}]+)\}/)[1];
assert.match(actions, /position: absolute/);
assert.match(actions, /bottom: 10px/);
assert.match(actions, /height: 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.doesNotMatch(css, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
for (const width of [1120, 1280, 1440, 1840]) {
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});