Added a badge to display the monthly ramp

This commit is contained in:
2026-09-23 23:24:31 -03:00
parent 3ef0d2aafd
commit b92d75f558
11 changed files with 229 additions and 21 deletions

View File

@@ -25,7 +25,12 @@ Use Node.js:
npm run build
```
The build recreates `dist/chromium` and `dist/firefox` from the same source.
The build updates `dist/chromium` and `dist/firefox` from the same source.
It preserves the output directories and unchanged files, replacing changed files
only after their complete contents have been written to a temporary file. Scripts
are published before the manifest, avoiding missing or partially written scripts
while an unpacked extension is loaded. Reload the extension and the Oracle page
after updating; clear historical extension errors to check for new failures.
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,8 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import composition from "./compose.cjs";
import output from "./write-output.cjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(rootDir, "dist");
@@ -61,10 +62,6 @@ const targets = [
}
];
if (!checkOnly) {
await rm(distDir, { recursive: true, force: true });
}
for (const target of targets) {
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
JSON.parse(manifestJson);
@@ -80,9 +77,9 @@ for (const target of targets) {
const outputDir = path.join(distDir, target.name);
await mkdir(outputDir, { recursive: true });
await writeFile(path.join(outputDir, "manifest.json"), manifestJson);
await writeFile(path.join(outputDir, "content.js"), contentScript);
await writeFile(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
}
if (!checkOnly) {

20
scripts/write-output.cjs Normal file
View File

@@ -0,0 +1,20 @@
const fs = require("node:fs/promises");
const { randomUUID } = require("node:crypto");
async function writeOutput(file, contents, io = fs) {
try {
if (await io.readFile(file, "utf8") === contents) return;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
// Replace a complete file; never remove or truncate a script the browser may load.
const temporary = `${file}.${randomUUID()}.tmp`;
try {
await io.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" });
await io.rename(temporary, file);
} finally {
await io.rm(temporary, { force: true });
}
}
module.exports = { writeOutput };

View File

@@ -204,6 +204,7 @@ function createContentApplication(overrides = {}) {
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),

View File

@@ -92,6 +92,41 @@
outline-offset: 2px;
}
.opportunities-extension-products-ramp {
display: inline-block;
min-width: 0;
max-width: 100%;
padding: 0 6px;
border: 1px solid #bba5cf;
border-radius: 8px;
background: #f0e8f7;
color: #63358a;
font-size: 10px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp {
background: #43334f;
color: #ead5fc;
border-color: #9372ac;
}
.opportunities-extension-products-ramp[data-missing="true"] {
background: #fde8e7;
color: #9f201b;
border-color: #c74640;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-missing="true"] {
background: #542824;
color: #ffd9d5;
border-color: #ef938b;
}
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button {
display: grid;
grid-template-columns: minmax(0, 1fr) 12px;

View File

@@ -9,6 +9,7 @@ function createOpportunitiesTable({
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
createProductsRampBadge,
createProductsDetailRow,
createStageBadge,
createStatusBadge,
@@ -128,7 +129,11 @@ function createOpportunitiesTable({
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));
if (productsResult) {
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
const rampBadge = createProductsRampBadge(productsResult);
if (rampBadge) productActions.append(rampBadge);
}
nameCellContent.append(opportunityLink, productActions);
cell.append(nameCellContent);
} else if (column.key === "optyNumber" && item.OptyNumber) {

View File

@@ -8,6 +8,21 @@ function createProductsDetails({
environment
}) {
const { document } = environment;
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.textContent = `RM ${values.join(",")}`;
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
return badge;
}
function createProductsCountButton(optyNumber, productsResult) {
const normalizedOptyNumber = String(optyNumber);
const button = document.createElement("button");
@@ -88,7 +103,8 @@ function createProductsDetails({
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => formatProductNumber(item.RampMonths_c) }
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) }
];
const head = document.createElement("thead");
@@ -148,5 +164,5 @@ function createProductsDetails({
return Number.isFinite(number) ? `${number}%` : "-";
}
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
}

View File

@@ -0,0 +1,47 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const vm = require("node:vm");
const { writeOutput } = require("../scripts/write-output.cjs");
test("build output keeps the previous script available until complete replacement", async t => {
const folder = await fs.mkdtemp(path.join(os.tmpdir(), "opportunities-build-"));
t.after(() => fs.rm(folder, { recursive: true, force: true }));
const file = path.join(folder, "background.js");
await writeOutput(file, "old script");
await writeOutput(file, "new script", { ...fs, async rename(from, to) {
assert.equal(await fs.readFile(to, "utf8"), "old script");
assert.equal(await fs.readFile(from, "utf8"), "new script");
return fs.rename(from, to);
} });
assert.equal(await fs.readFile(file, "utf8"), "new script");
await writeOutput(file, "new script", { ...fs, writeFile() { assert.fail("Unchanged scripts must not be rewritten"); } });
for (const operation of ["writeFile", "rename"]) {
await assert.rejects(writeOutput(file, "failed update", { ...fs, async [operation]() {
throw new Error("Synthetic publication failure");
} }), /Synthetic publication failure/);
assert.equal(await fs.readFile(file, "utf8"), "new script");
assert.deepEqual(await fs.readdir(folder), ["background.js"]);
}
});
test("packaged background scripts start and register listeners without DOM or network", async () => {
for (const target of ["chromium", "firefox"]) {
const folder = path.join(__dirname, "../dist", target);
const manifest = JSON.parse(await fs.readFile(path.join(folder, "manifest.json"), "utf8"));
const files = manifest.background.scripts || [manifest.background.service_worker];
const listeners = [];
const api = { runtime: { onMessage: { addListener: handler => listeners.push(handler) } } };
const context = vm.createContext({ [target === "firefox" ? "browser" : "chrome"]: api,
setTimeout, fetch() { assert.fail("No startup network requests"); } });
for (const file of files) {
new vm.Script(await fs.readFile(path.join(folder, file), "utf8"), { filename: file }).runInContext(context);
}
assert.equal(listeners.length, 1, target);
for (const entry of manifest.content_scripts) {
for (const file of entry.js) new vm.Script(await fs.readFile(path.join(folder, file), "utf8"));
}
}
});

View File

@@ -68,6 +68,29 @@ 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("ramp months preserve zero and flag missing values beside the product count", () => {
const h = setup();
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
});
h.app.opportunitiesTable.renderOpportunitiesTable();
if (expected === null) assert.equal(badge(), undefined);
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.match(badge().title, /^Ramp Months:/);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
}
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count").textContent, String(values.length));
}
});
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" }] });
@@ -86,6 +109,18 @@ test("products remain expandable with AI summary and a ten-column detail row aft
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
});
test("product table distinguishes unavailable ramp months from numeric zero", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
items: [null, undefined, "", " ", 0, "0", 6].map(RampMonths_c => ({ RampMonths_c }))
});
h.app.opportunitiesTable.renderOpportunitiesTable();
findClass(h.cell("name"), "opportunities-extension-products-count").click();
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
const rows = table.children.find(child => child.tag === "tbody").children;
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/D", "N/D", "N/D", "N/D", "0", "0", "6"]);
});
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
const h = setup();
const opens = [];
@@ -151,7 +186,10 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(css, /flex: 0 0 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/);
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "");
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
for (const width of [1120, 1280, 1440, 1840]) {
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);