Added a badge to display the monthly ramp
This commit is contained in:
@@ -25,7 +25,12 @@ Use Node.js:
|
|||||||
npm run build
|
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
|
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
|
||||||
|
|||||||
30
dist/chromium/content.js
vendored
30
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
30
dist/firefox/content.js
vendored
30
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import composition from "./compose.cjs";
|
import composition from "./compose.cjs";
|
||||||
|
import output from "./write-output.cjs";
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const distDir = path.join(rootDir, "dist");
|
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) {
|
for (const target of targets) {
|
||||||
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
|
const manifestJson = `${JSON.stringify(target.manifest, null, 2)}\n`;
|
||||||
JSON.parse(manifestJson);
|
JSON.parse(manifestJson);
|
||||||
@@ -80,9 +77,9 @@ for (const target of targets) {
|
|||||||
|
|
||||||
const outputDir = path.join(distDir, target.name);
|
const outputDir = path.join(distDir, target.name);
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
await writeFile(path.join(outputDir, "manifest.json"), manifestJson);
|
await output.writeOutput(path.join(outputDir, "content.js"), contentScript);
|
||||||
await writeFile(path.join(outputDir, "content.js"), contentScript);
|
await output.writeOutput(path.join(outputDir, "background.js"), backgroundScript);
|
||||||
await writeFile(path.join(outputDir, "background.js"), backgroundScript);
|
await output.writeOutput(path.join(outputDir, "manifest.json"), manifestJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkOnly) {
|
if (!checkOnly) {
|
||||||
|
|||||||
20
scripts/write-output.cjs
Normal file
20
scripts/write-output.cjs
Normal 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 };
|
||||||
@@ -204,6 +204,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
|
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||||
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||||
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||||
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
|
createStatusBadge: (...args) => opportunitiesLinks.createStatusBadge(...args),
|
||||||
|
|||||||
@@ -92,6 +92,41 @@
|
|||||||
outline-offset: 2px;
|
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 {
|
.opportunities-extension-table > thead > tr > th[data-column] .opportunities-extension-sort-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 12px;
|
grid-template-columns: minmax(0, 1fr) 12px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function createOpportunitiesTable({
|
|||||||
createCopyOpportunityButton,
|
createCopyOpportunityButton,
|
||||||
createDetailLink,
|
createDetailLink,
|
||||||
createProductsCountButton,
|
createProductsCountButton,
|
||||||
|
createProductsRampBadge,
|
||||||
createProductsDetailRow,
|
createProductsDetailRow,
|
||||||
createStageBadge,
|
createStageBadge,
|
||||||
createStatusBadge,
|
createStatusBadge,
|
||||||
@@ -128,7 +129,11 @@ function createOpportunitiesTable({
|
|||||||
const productActions = document.createElement("span");
|
const productActions = document.createElement("span");
|
||||||
productActions.className = "opportunities-extension-cell-actions";
|
productActions.className = "opportunities-extension-cell-actions";
|
||||||
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
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);
|
nameCellContent.append(opportunityLink, productActions);
|
||||||
cell.append(nameCellContent);
|
cell.append(nameCellContent);
|
||||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ function createProductsDetails({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = 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) {
|
function createProductsCountButton(optyNumber, productsResult) {
|
||||||
const normalizedOptyNumber = String(optyNumber);
|
const normalizedOptyNumber = String(optyNumber);
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -88,7 +103,8 @@ function createProductsDetails({
|
|||||||
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
|
{ label: "Win Probability", value: (item) => formatProductWinProbability(item.WinProb) },
|
||||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
{ 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");
|
const head = document.createElement("thead");
|
||||||
@@ -148,5 +164,5 @@ function createProductsDetails({
|
|||||||
return Number.isFinite(number) ? `${number}%` : "-";
|
return Number.isFinite(number) ? `${number}%` : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||||
}
|
}
|
||||||
|
|||||||
47
tests/buildOutput.test.cjs
Normal file
47
tests/buildOutput.test.cjs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
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", () => {
|
test("products remain expandable with AI summary and a ten-column detail row after sorting", () => {
|
||||||
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
|
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("B", { items: [{ ProdGroupName: "Cloud", RevnAmount: "10" }] });
|
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");
|
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 () => {
|
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
const opens = [];
|
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, /flex: 0 0 24px/);
|
||||||
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
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]) {
|
for (const width of [1120, 1280, 1440, 1840]) {
|
||||||
const remaining = width - fixed;
|
const remaining = width - fixed;
|
||||||
assert.equal(fixed + remaining / 2 + remaining / 2, width);
|
assert.equal(fixed + remaining / 2 + remaining / 2, width);
|
||||||
|
|||||||
Reference in New Issue
Block a user