160 lines
9.4 KiB
JavaScript
160 lines
9.4 KiB
JavaScript
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(customer.children[1].children[1].title, "View customer installed base");
|
|
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", "src/features/install-base/styles.css"].map(read).join("\n");
|
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
|
});
|