Implemented the calendar view for opportunities
This commit is contained in:
@@ -4,7 +4,7 @@ const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { test } = require("node:test");
|
||||
const { sources, composeScript } = require("../scripts/compose.cjs");
|
||||
const { sources, vendorSources, composeScript } = require("../scripts/compose.cjs");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
@@ -14,7 +14,13 @@ const json = value => JSON.parse(JSON.stringify(value));
|
||||
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
|
||||
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
|
||||
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
|
||||
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
|
||||
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
|
||||
for (const files of Object.values(vendorSources)) {
|
||||
for (const file of files) {
|
||||
assert.doesNotThrow(() => new vm.Script(read(file)));
|
||||
assert.doesNotMatch(read(file), /\beval\s*\(|\bnew\s+Function\s*\(/, file);
|
||||
}
|
||||
}
|
||||
for (const [target, files] of Object.entries(sources)) {
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));
|
||||
|
||||
@@ -269,7 +269,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"]
|
||||
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"]
|
||||
.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"));
|
||||
|
||||
3
tests/fixtures/extension.js
vendored
3
tests/fixtures/extension.js
vendored
@@ -7,7 +7,7 @@
|
||||
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
|
||||
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
|
||||
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
|
||||
EffectiveDate: "2026-11-15T00:00:00Z",
|
||||
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
|
||||
LastUpdateDate: "2026-09-15T17:00:00Z",
|
||||
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
|
||||
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
|
||||
@@ -28,6 +28,7 @@
|
||||
onMessage: { addListener: listener => listeners.push(listener) },
|
||||
async sendMessage(message) {
|
||||
const operation = message.type.split(".").at(-1);
|
||||
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: ["SALES_REP"] }, debugRequests: [] };
|
||||
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
|
||||
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
|
||||
if (operation === "requestOpptyProducts") {
|
||||
|
||||
227
tests/opportunityCalendar.test.cjs
Normal file
227
tests/opportunityCalendar.test.cjs
Normal file
@@ -0,0 +1,227 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { test } = require("node:test");
|
||||
|
||||
const factory = new vm.Script(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/calendar.js"), "utf8")}\ncreateOpportunityCalendar;`).runInNewContext();
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
|
||||
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
|
||||
|
||||
function setup(items = [opportunity("A", "2026-11-15")]) {
|
||||
let document;
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
||||
set textContent(value) { this.text = value; this.children = []; }
|
||||
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||
append(...nodes) { for (const child of nodes) { child.parent = this; this.children.push(child); } }
|
||||
setAttribute(key, value) { this.attributes[key] = value; }
|
||||
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||
focus() { document.activeElement = this; }
|
||||
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
|
||||
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
|
||||
}
|
||||
const host = new Element("host");
|
||||
document = { createElement: tag => new Element(tag), getElementById: () => host };
|
||||
const state = { status: "ready", items };
|
||||
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
|
||||
class Calendar {
|
||||
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
|
||||
render() { this.rendered = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
updateSize() { this.resized = true; }
|
||||
batchRendering(fn) { fn(); }
|
||||
gotoDate(date) { this.dates.push(date); }
|
||||
removeAllEvents() { this.events = []; }
|
||||
addEventSource(events) { this.events = events; }
|
||||
}
|
||||
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
|
||||
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
|
||||
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
|
||||
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
|
||||
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
|
||||
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
|
||||
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
|
||||
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
|
||||
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
|
||||
const walk = element => [element, ...element.children.flatMap(walk)];
|
||||
h.nodes = () => walk(host);
|
||||
h.byClass = name => h.nodes().find(element => element.className === name);
|
||||
h.mount = () => h.api.mount(h.body, h.table);
|
||||
h.activate = () => { h.mount(); h.api.selectView("calendar"); return h.instances[0]; };
|
||||
return h;
|
||||
}
|
||||
|
||||
test("close dates retain the API day across offsets and reject invalid dates", () => {
|
||||
const { api } = setup();
|
||||
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
|
||||
assert.equal(api.closeDate("2028-02-29"), "2028-02-29");
|
||||
for (const value of [null, undefined, "", "2026-02-29", "2026-13-01", "2026-04-31", "15/11/2026", 123]) assert.equal(api.closeDate(value), null);
|
||||
});
|
||||
|
||||
test("fiscal week labels use June-May years and Sunday weeks containing June 1", () => {
|
||||
const { api } = setup();
|
||||
for (const [day, expected] of [
|
||||
["2026-05-24", "FY26-Q4-W52"], ["2026-05-31", "FY27-Q1-W1"],
|
||||
["2026-06-01", "FY27-Q1-W1"], ["2026-06-07", "FY27-Q1-W2"],
|
||||
["2026-09-06", "FY27-Q2-W15"], ["2026-10-04", "FY27-Q2-W19"],
|
||||
["2026-11-15", "FY27-Q2-W25"], ["2026-12-06", "FY27-Q3-W28"],
|
||||
["2027-03-07", "FY27-Q4-W41"], ["2027-05-30", "FY28-Q1-W1"],
|
||||
["2028-02-27", "FY28-Q3-W40"], ["2028-06-04", "FY29-Q1-W2"]
|
||||
]) assert.equal(api.fiscalWeekLabel(new Date(`${day}T00:00:00Z`)), expected, day);
|
||||
});
|
||||
|
||||
test("daily count badges follow filters and are cleaned up when cells unmount", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
|
||||
const calendar = h.activate();
|
||||
const frame = h.document.createElement("div");
|
||||
const el = { querySelector: selector => { assert.equal(selector, ".fc-daygrid-day-frame"); return frame; } };
|
||||
calendar.options.dayCellDidMount({ el, date: new Date(2026, 10, 15) });
|
||||
const badge = frame.children[0];
|
||||
assert.equal(badge.textContent, "3");
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
|
||||
h.api.render([h.state.items[0]], h.state);
|
||||
assert.equal(badge.textContent, "1");
|
||||
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
|
||||
h.api.render([], h.state);
|
||||
assert.equal(badge.textContent, "0");
|
||||
assert.equal(badge.attributes["data-empty"], "true");
|
||||
assert.equal(badge.hidden, true);
|
||||
h.api.render(h.state.items, h.state);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, "3");
|
||||
calendar.options.dayCellWillUnmount({ el });
|
||||
assert.equal(frame.children.length, 0);
|
||||
});
|
||||
|
||||
test("calendar events use EffectiveDate, all-day semantics and literal untrusted names", () => {
|
||||
const { api } = setup();
|
||||
const events = plain(api.eventsFor([opportunity("A", "2026-11-15"), opportunity("B", null)]));
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].start, "2026-11-15");
|
||||
assert.equal(events[0].allDay, true);
|
||||
assert.equal(events[0].title, "<Opportunity A>");
|
||||
assert.deepEqual(events[0].classNames, ["opportunities-calendar-stage-FORECAST"]);
|
||||
});
|
||||
|
||||
test("initial calendar date selects the next close date, last past date or today", () => {
|
||||
const { api } = setup();
|
||||
const now = new Date(2026, 9, 5);
|
||||
assert.equal(api.initialDate([opportunity("A", "2026-11-15"), opportunity("B", "2026-10-12")], now), "2026-10-12");
|
||||
assert.equal(api.initialDate([opportunity("A", "2026-09-01"), opportunity("B", "2026-10-01")], now), "2026-10-01");
|
||||
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
|
||||
});
|
||||
|
||||
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
|
||||
const h = setup(); h.mount();
|
||||
const surface = h.byClass("opportunities-extension-calendar");
|
||||
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
|
||||
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
|
||||
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
|
||||
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
|
||||
assert.equal(tabs[0].attributes["aria-selected"], "true");
|
||||
tabs[1].click();
|
||||
const instance = h.instances[0];
|
||||
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
|
||||
assert.equal(instance.options.initialView, "dayGridMonth");
|
||||
assert.equal(instance.options.height, "100%");
|
||||
assert.equal(instance.options.expandRows, true);
|
||||
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
|
||||
assert.equal(instance.options.editable, false);
|
||||
assert.equal(instance.options.dayMaxEvents, 2);
|
||||
assert.equal(instance.options.moreLinkText(7), "... +7");
|
||||
assert.equal(instance.options.weekNumbers, true);
|
||||
tabs[0].click(); tabs[1].click();
|
||||
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
|
||||
tabs[1].listeners.keydown({ key: "Home", preventDefault() {} });
|
||||
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
|
||||
});
|
||||
|
||||
test("responsive event limit reserves room for every week and the overflow control", () => {
|
||||
const { api } = setup();
|
||||
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
|
||||
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
|
||||
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
|
||||
});
|
||||
|
||||
test("calendar follows filtered data; only a new Reference request changes its date", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
|
||||
const calendar = h.activate();
|
||||
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
|
||||
assert.equal(calendar.dates.length, 0);
|
||||
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
|
||||
h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(calendar.dates, ["2027-02-20"]);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
|
||||
h.filtered = []; h.api.render(h.filtered, h.state);
|
||||
assert.equal(calendar.events.length, 0);
|
||||
assert.equal(h.byClass("opportunities-calendar-message").textContent, "No opportunities found.");
|
||||
});
|
||||
|
||||
test("undated opportunities remain visible and open read-only details", () => {
|
||||
const h = setup([opportunity("A", null)]); const calendar = h.activate();
|
||||
assert.equal(calendar.events.length, 0);
|
||||
const undated = h.byClass("opportunities-calendar-undated");
|
||||
assert.equal(undated.hidden, false); undated.children[1].click();
|
||||
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
|
||||
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
|
||||
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
|
||||
assert.equal(h.api.closeDetails(), true);
|
||||
assert.equal(h.document.activeElement, undated.children[1]);
|
||||
});
|
||||
|
||||
test("event details close on changed data, loading/error hide stale calendar content", () => {
|
||||
const h = setup(); const calendar = h.activate();
|
||||
calendar.options.eventClick({ event: { extendedProps: { item: h.state.items[0] } }, el: h.table, jsEvent: { preventDefault() {} } });
|
||||
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.render([], h.state);
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
for (const status of ["loading", "error", "empty"]) {
|
||||
h.api.render([], { status, items: [], message: "State message" });
|
||||
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
|
||||
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
|
||||
assert.equal(h.byClass("opportunities-calendar-message").textContent, "State message");
|
||||
}
|
||||
});
|
||||
|
||||
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
|
||||
const calendar = h.activate();
|
||||
const trigger = h.document.createElement("a");
|
||||
const allSegs = calendar.events.map(event => ({ event }));
|
||||
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
|
||||
jsEvent: { currentTarget: trigger, preventDefault() {} } });
|
||||
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
|
||||
const list = h.byClass("opportunities-calendar-day-list");
|
||||
assert.equal(list.children.length, 3);
|
||||
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
|
||||
list.children[2].click();
|
||||
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
|
||||
h.api.closeDetails();
|
||||
assert.equal(h.document.activeElement, trigger);
|
||||
open(); h.api.selectView("overview");
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.selectView("calendar"); open();
|
||||
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
|
||||
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
});
|
||||
|
||||
test("calendar events show their stage and handle missing stage values", () => {
|
||||
const h = setup();
|
||||
const calendar = h.activate();
|
||||
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
|
||||
const item = { ...h.state.items[0], ForecastGroup_c: value };
|
||||
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
|
||||
assert.equal(domNodes[0].children.length, 2);
|
||||
assert.equal(domNodes[0].children[0].textContent, item.Name);
|
||||
assert.equal(domNodes[0].children[1].textContent, `A | ${label} | $100`);
|
||||
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
|
||||
}
|
||||
});
|
||||
@@ -254,6 +254,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.ok(remaining > 0);
|
||||
assert.ok(remaining / 2 >= 12 + 84, "three 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"].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"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user