Implemented filters for Service Requests and Actions

This commit is contained in:
2026-09-25 16:37:29 -03:00
parent cc567c8ff1
commit de4da0eec6
26 changed files with 1001 additions and 99 deletions

View File

@@ -52,6 +52,9 @@ extension local storage, isolated by tokenrelay principal. The cache uses the sa
Preferences lifetime as Products (24 hours by default), anchored to the product
timestamp rather than the activity response time. Actions entries match each
product's update; the SR snapshot matches the latest product update in the cycle.
SR snapshots are stored separately by update timestamp so changing Reference does
not overwrite a previous period's snapshot. Expired snapshots are removed on
subsequent SR cache writes; Refresh all invalidates every snapshot.
No credentials, request headers or debug envelopes are stored in this cache.
Products loaded from cache restore matching activity entries without network calls.
Missing, expired or mismatched entries are not treated as confirmed empty results;
@@ -65,6 +68,15 @@ finish in the background and update their related activities.
## Workload Actions
SR and Action filter badges appear before Status for users allowed to view these
activities. They filter the loaded data without issuing requests and combine with
the existing opportunity filters. The SR below 4h option replaces the other SR
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
(including zero, excluding missing values). Action matches use the same ownership
rule as the row badge. Loading/failed lookups are not treated as missing records;
with both positive and negative options selected, all activity states remain
visible. These selections are kept in memory, not persisted.
Workload Actions are queried in the background after the product cycle completes,
using only fresh API results. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,6 +36,7 @@ module.exports = {
"src/features/opportunities/refresh-all.js",
"src/features/opportunities/activity-cache.js",
"src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/fiscal-periods.js",

View File

@@ -55,6 +55,7 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
@@ -229,6 +230,7 @@ function createContentApplication(overrides = {}) {
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesTable = createOpportunitiesTable({
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
@@ -266,6 +268,7 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
@@ -352,12 +355,22 @@ function createContentApplication(overrides = {}) {
}
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const activityFilters = createActivityFilters({
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
environment
});
const productActivities = createProductActivities({
productsContentState, opportunitiesQueryState,
resetCrm: () => opportunityCrmServiceRequests.reset(),
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
render: () => productsContentJobs.queueProductsTableRender()
render: () => {
productsContentJobs.renderProductsProgress();
productsContentJobs.queueProductsTableRender();
}
});
const productsContentJobs = createProductsContentJobs({
beginProductActivities: (...args) => productActivities.begin(...args),
@@ -548,6 +561,7 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
activityFilters,
start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,

View File

@@ -33,6 +33,7 @@ function createContentModal({
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createActivityFilterControls,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -380,7 +381,8 @@ function createContentModal({
statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault());
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
body.append(form);
const stageDashboard = document.createElement("section");

View File

@@ -222,7 +222,14 @@ function createDebugContentView({
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase());
summaryMeta.append(startedTime, method, httpStatus, status);
const duration = document.createElement("span");
duration.className = "opportunities-extension-debug-duration";
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta);

View File

@@ -214,3 +214,10 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline-color: #8cc8d3;
}
.opportunities-extension-debug-duration {
color: #55514d;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }

View File

@@ -24,7 +24,11 @@ function createActivityCache({ authSessionState, preferencesState, environment }
const hours = Number(preferencesState.opptyProductsCacheHours);
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify([principal, kind, String(id)])}`, fetchedAt };
const identity = [principal, kind, String(id)];
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
// SR snapshots from different product updates must not overwrite each other.
if (kind === "sr") identity.push(fetchedAt);
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
}
async function read(kind, id, timestamp) {
const scope = context(kind, id, timestamp);
@@ -33,7 +37,11 @@ function createActivityCache({ authSessionState, preferencesState, environment }
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
const result = await storage("get", scope.key);
const entry = result?.[scope.key];
let entry = result?.[scope.key];
if (!entry && scope.key !== scope.legacyKey) {
const legacy = await storage("get", scope.legacyKey);
entry = legacy?.[scope.legacyKey];
}
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
&& typeof entry.hasNext === "boolean" ? entry : null;
@@ -46,6 +54,13 @@ function createActivityCache({ authSessionState, preferencesState, environment }
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
if (kind === "sr") {
const entries = await storage("get", null);
const prefix = `${scope.legacyKey.slice(0, -1)},`;
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
if (expired.length) await storage("remove", expired);
}
} catch { /* Storage failure must not discard successfully fetched data. */ }
}
return { invalidate, read, write };

View File

@@ -0,0 +1,83 @@
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, environment }) {
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
let container = null;
let form = null;
const groups = [];
const buttons = [];
function createControls(parentForm, statusFilter, stageFilter) {
form = parentForm;
groups.length = 0;
buttons.length = 0;
container = environment.document.createElement("div");
container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [
["sr", "Service Request", [["matched", "\u2705 SR"], ["under4", "\u2705 SR < 4h"], ["missing", "\u26d4 SR"]]],
["actions", "Actions", [["matched", "\u2705 Action"], ["missing", "\u26d4 Action"]]]
]) {
const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter";
const label = environment.document.createElement("span");
label.textContent = title;
const controls = environment.document.createElement("div");
controls.className = "opportunities-extension-activity-filter-controls";
controls.setAttribute("role", "group");
controls.setAttribute("aria-label", `Filter by ${title}`);
for (const [value, text] of options) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-activity-filter-button";
button.setAttribute("data-value", value);
button.textContent = text;
if (value === "under4") button.title = "At least one SR with reported hours below 4; replaces other SR filters.";
button.addEventListener("click", () => toggle(kind, value));
buttons.push({ button, kind, value });
controls.append(button);
}
section.append(label, controls);
groups.push(section);
container.append(section);
}
container.append(statusFilter, stageFilter);
render();
return container;
}
function render() {
if (!container) return;
const allowed = allowsCrmActivities();
form.setAttribute("data-activity-filters", String(allowed));
for (const group of groups) group.hidden = !allowed;
for (const { button, kind, value } of buttons) {
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
}
}
function toggle(kind, value) {
const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value);
else {
if (value === "under4") selected.clear();
else selected.delete("under4");
selected.add(value);
}
render();
renderTable();
}
function matchesSelection(selected, summary) {
// Unrestricted filters retain rows whose activities are not known yet.
if (selected.has("matched") && selected.has("missing")) return true;
if (!summary) return false;
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
return selected.has(summary.state);
}
function matches(item) {
if (!allowsCrmActivities()) return true;
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
}
return { createControls, render, toggle, matches };
}

View File

@@ -69,7 +69,6 @@ function createOpportunitiesController({
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();

View File

@@ -40,17 +40,27 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
if (isActive()) { status = "error"; reportProgress("error"); }
}
}
function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const hours = items.map(item => item.ReportedHours_c).filter(value =>
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number);
return {
state: status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
};
}
function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
const hours = items.map(item => item.ReportedHours_c).filter(value =>
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number);
const reportedHours = hours.length === items.length
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(hours.reduce((total, value) => total + value, 0))
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
badge.textContent = status === "error" ? "SR ?" : items.length ? `\u2705 SR (${reportedHours}${reportedHours === "N/D" ? "" : "h"})` : "\u26d4 SR";
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
@@ -71,5 +81,5 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge };
return { reset, load, createBadge, getSummary };
}

View File

@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
environment
}) {
const { document } = environment;
@@ -340,7 +341,8 @@ function createOpportunitiesFilters({
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch);
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);

View File

@@ -12,6 +12,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return;
resetCrm();
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
@@ -20,11 +21,15 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id);
return true;
});
loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true });
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
}
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).finally(() => {
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render();
pending.push(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) }));
return Promise.allSettled(pending).then(() => {
if (cycle !== current || productsContentState.currentProductsRequestId !== requestId
|| opportunitiesQueryState.periodRequestVersion !== current.version) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => {});
}
return { begin, complete };

View File

@@ -8,7 +8,6 @@ function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, res
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsLastCachedAt = 0;
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();

View File

@@ -31,14 +31,15 @@
.opportunities-extension-form {
display: grid;
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
align-items: end;
gap: 12px;
min-height: 0;
padding: 12px;
}
.opportunities-extension-form > * {
.opportunities-extension-form > *,
.opportunities-extension-badge-filters > section {
min-width: 0;
width: 100%;
max-width: none;
@@ -47,7 +48,8 @@
font-size: 12px;
}
.opportunities-extension-form > * > span:first-child {
.opportunities-extension-form > * > span:first-child,
.opportunities-extension-badge-filters > section > span:first-child {
font-size: 12px;
line-height: 16px;
}
@@ -97,22 +99,12 @@
white-space: nowrap;
}
/* Native select intrinsic sizing includes its longest option, not just the selection. */
@media (min-width: 1151px) {
.opportunities-extension-form > .opportunities-extension-field,
.opportunities-extension-form .opportunities-extension-select-wrap,
.opportunities-extension-form .opportunities-extension-field select {
width: max-content;
max-width: none;
}
}
@media (max-width: 1150px) {
.opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-column: span 2;
}
}
@@ -125,16 +117,73 @@
.opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: wrap;
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-template-rows: auto auto;
}
}
@media (max-width: 420px) {
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
}
.opportunities-extension-badge-filters { display: contents; }
.opportunities-extension-activity-filter { display: grid; }
.opportunities-extension-activity-filter[hidden] { display: none; }
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
/* Flexible fields share the space left by the four compact badge groups. */
.opportunities-extension-form[data-activity-filters="true"] {
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
column-gap: 8px;
}
.opportunities-extension-activity-filter-button {
border: 1px solid currentColor;
border-radius: 18px;
min-height: 28px;
padding: 4px 7px;
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
color: #185b30;
background: transparent;
cursor: pointer;
}
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
@media (max-width: 1350px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
align-items: start;
gap: 12px 20px;
}
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
width: auto;
max-width: 100%;
grid-template-rows: auto auto;
}
}
@media (max-width: 700px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
}
@media (max-width: 420px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
}
/* Keep layout rules on the outer table, never on expanded product tables. */

View File

@@ -23,6 +23,7 @@ function createOpportunitiesTable({
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
resetCustomerFilter,
resetOwnerFilter,
@@ -47,6 +48,7 @@ function createOpportunitiesTable({
}
function renderOpportunitiesTable() {
renderActivityFilters();
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {

View File

@@ -11,6 +11,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
}
function reset(enabled = true) {
session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
@@ -21,17 +22,24 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
}
function loadProducts(products, refresh = false, cacheOptions = null) {
if (!session.enabled || !allowsCrmActivities()) return;
const pending = [];
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
for (const revnId of idsFor(products)) {
if (!refresh && session.records.has(revnId)) continue;
if (!refresh && session.records.has(revnId)) {
pending.push(session.records.get(revnId).completion);
continue;
}
session.progress.delete(session.records.get(revnId));
const record = { status: "loading", actions: [], hasNext: false };
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
pending.push(record.completion);
session.records.set(revnId, record);
if (!cacheOptions) session.progress.add(record);
session.queue.push({ revnId, record, cacheOptions });
}
updateProgress(session);
pump(session);
return Promise.all(pending);
}
function reopen() {
reset();
@@ -73,10 +81,11 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
record.status = "error";
record.error = error.message || "Unable to load workload actions.";
} finally {
record.resolveCompletion();
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
}
}
function createBadge(optyNumber) {
function getSummary(optyNumber) {
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
@@ -89,6 +98,12 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
const principal = authSessionState.principal;
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
return { state, actions, records, principal, pending, uncertain };
}
function createBadge(optyNumber) {
const summary = getSummary(optyNumber);
if (!summary) return null;
const { state, actions, records, principal, pending, uncertain } = summary;
const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
badge.setAttribute("data-state", state);
@@ -107,5 +122,5 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
badge.setAttribute("aria-label", badge.title);
return badge;
}
return { reset, reopen, loadProducts, createBadge };
return { reset, reopen, loadProducts, createBadge, getSummary };
}

View File

@@ -123,10 +123,6 @@ function createProductsContentJobs({
});
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
productsContentState.productsLastCachedAt = Number(message.cachedAt);
}
productsContentState.productsProgress = {
state: message.state || productsContentState.productsProgress.state,
completed: Number(message.completed) || 0,

View File

@@ -4,12 +4,14 @@ const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function setup(family, saved = {}, principal = "user@example.invalid") {
const local = {
get: async key => ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values)))
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
remove: async keys => keys.forEach(key => delete saved[key])
};
const api = family === "firefox" ? { browser: { storage: { local } } } : {
chrome: { runtime: {}, storage: { local: {
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done)
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
remove: (keys, done) => local.remove(keys).then(done)
} } }
};
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } });
@@ -41,6 +43,34 @@ function setup(family, saved = {}, principal = "user@example.invalid") {
return { app, calls, load, saved, local };
}
for (const family of ["chrome", "firefox"]) {
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
const h = setup(family);
const currentStamp = Date.now() - 2000;
const nextStamp = currentStamp + 1000;
let hours = 2;
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
h.calls.push("sr");
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
};
await h.load(currentStamp, false);
hours = 8;
await h.load(nextStamp, false);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
const count = h.calls.length;
await h.load(currentStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (2h)");
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
h.app.activityFilters.toggle("sr", "under4");
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
await h.load(nextStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
const reopened = setup(family, h.saved);
await reopened.load(currentStamp, true);
assert.deepEqual(reopened.calls, []);
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
});
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
const h = setup(family);
const stamp = Date.now() - 1000;
@@ -78,6 +108,26 @@ test("TTL follows product preferences, rejects stale/mismatched cache and stores
await h.load(stamp + 1, true);
assert.deepEqual(h.calls, []);
});
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
const h = setup("firefox");
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
const stamp = Date.now() - 1000;
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
const expiredStamp = Date.now() - 25 * 3600000;
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
h.saved.preferences = { keep: true };
await h.app.activityCache.write("sr", "all", stamp + 1, []);
assert.equal(h.saved[expiredKey], undefined);
assert.ok(h.saved[legacyKey]);
assert.equal(h.saved.preferences.keep, true);
await h.app.activityCache.invalidate();
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
assert.equal(h.saved.preferences.keep, true);
});
test("storage failure does not lose live activity results and failures are never cached", async () => {
const h = setup("firefox");
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };

View File

@@ -0,0 +1,201 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, handler) { this.listeners[key] = handler; }
}
function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
let renders = 0;
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
const sr = new Map(), actions = new Map();
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
const controls = app.activityFilters.createControls(form, status, stage);
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
matches: number => app.activityFilters.matches({ OptyNumber: number }),
click: (kind, value) => button(kind, value).listeners.click() };
}
test("activity badge filters precede Status, are accessible and combine without network", () => {
const h = setup();
assert.equal(h.controls.children[2], h.status);
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
assert.equal(h.controls.children[3], h.stage);
assert.equal(h.form.attributes["data-activity-filters"], "true");
assert.equal(h.button("sr", "matched").textContent, "\u2705 SR");
assert.equal(h.button("actions", "missing").textContent, "\u26d4 Action");
assert.equal(h.button("actions", "matched").type, "button");
h.sr.set("A", { state: "matched" });
h.actions.set("A", { state: "missing" });
h.sr.set("B", { state: "missing" });
h.actions.set("B", { state: "matched" });
assert.equal(h.matches("unknown"), true);
h.click("sr", "missing");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
assert.equal(h.matches("unknown"), false);
h.click("actions", "missing");
assert.equal(h.matches("A"), false, "SR and Action use AND");
h.actions.set("A", { state: "matched" });
assert.equal(h.matches("A"), true);
assert.equal(h.renders(), 2);
});
test("SR below four hours is exclusive and regular SR options can replace it", () => {
const h = setup();
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
h.click("sr", "under4");
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
h.click("sr", "matched");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("B"), true);
h.click("sr", "matched");
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
h.click("sr", "under4");
h.click("sr", "missing");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("A"), false);
});
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
const h = setup();
h.click("sr", "matched");
h.click("actions", "matched");
for (const state of ["idle", "loading", "error", "matched"]) {
h.sr.set("A", { state });
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), false);
h.sr.set("A", { state: "missing" });
h.actions.set("A", { state });
assert.equal(h.matches("A"), false);
}
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), true);
h.app.currentUser.allowsCrmActivities = () => false;
h.app.activityFilters.render();
assert.equal(h.matches("unknown"), true);
assert.equal(h.controls.children[0].hidden, true);
assert.equal(h.controls.children[1].hidden, true);
assert.equal(h.form.attributes["data-activity-filters"], "false");
});
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
const calls = [];
let finish;
app.platformContentMessages.sendRuntimeMessage = message => {
calls.push(message.type);
return new Promise(resolve => { finish = resolve; });
};
app.opportunitiesTable.renderOpportunitiesTable = () => {};
const form = new Element("form");
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } });
await identity;
app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed);
assert.equal(controls.children[1].hidden, !allowed);
if (!allowed) {
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
app.activityFilters.toggle("sr", "under4");
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
}
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
});
}
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
const app = loadApplication("content");
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.activityProgress.update = () => {};
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
];
await app.opportunityCrmServiceRequests.load("synthetic", 1);
for (const [index, [, expected]] of examples.entries()) {
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
}
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
});
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {};
app.authSessionState.principal = "me@example.invalid";
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
const products = { items: [{ RevnId: "123" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
await app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
app.authSessionState.principal = "other@example.invalid";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
app.authSessionState.principal = "";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
});
test("activity filters compose with existing search, status, owner and customer filters", () => {
const h = setup();
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
h.app.opportunitiesFilters.resetCustomerFilter(items);
h.app.opportunitiesFilters.resetOwnerFilter(items);
h.sr.set("A", { state: "matched" });
h.sr.set("B", { state: "missing" });
h.click("sr", "missing");
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
assert.deepEqual(results(), ["A"]);
h.app.opportunitiesFilterState.tableSearch = "Test B";
assert.deepEqual(results(), []);
h.app.opportunitiesFilterState.tableSearch = "";
h.app.opportunitiesFilterState.selectedStatuses.clear();
assert.deepEqual(results(), []);
});
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /activity-filter-button:focus-visible/);
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
});
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
const breakpoint = css.indexOf("@media (max-width: 1350px)");
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
});

View File

@@ -20,6 +20,19 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("request summaries show duration after status, including zero, failure and pending", () => {
const h = fixture();
h.add("duration", "requestOpportunities", 1);
const entry = h.app.debugState.requestLog.at(-1);
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
Object.assign(entry, { durationMs, status });
h.app.debugContentView.renderDebugPanel();
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
assert.equal(meta.children.at(-1).textContent, expected);
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
}
});
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");

View File

@@ -69,10 +69,11 @@ 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("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
test("all four filter fields share available width dynamically with responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
assert.match(css, /@media \(max-width: 1150px\)/);
});

View File

@@ -2,6 +2,83 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
for (const first of ["SR", "Actions"]) {
test(`Last update waits for every background call when ${first} finishes first`, async () => {
const h = setup();
const sr = deferred(), actions = deferred();
let renders = 0;
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
const id = h.start();
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.progress(id, { state: "complete" });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? sr : actions).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? actions : sr).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
assert.equal(renders, 1, "publish the completed cycle once");
});
}
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
const h = setup();
const old = deferred(), current = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => old.promise;
const first = h.start();
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
h.app.opportunityCrmServiceRequests.load = () => current.promise;
const second = h.start();
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
old.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
current.reject(new Error("Synthetic failure"));
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 300);
});
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
const h = setup();
const restore = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.app.opportunitiesQueryState.periodRequestVersion += 1;
restore.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
const next = h.start();
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
test("Last update completes for SALES_REP without activity requests", async () => {
const h = setup();
h.app.opportunityCrmServiceRequests.load = () => undefined;
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
function setup() {
let listener;

View File

@@ -72,10 +72,12 @@ test("Refresh all deduplicates clicks, clears memory before reload and preserves
app.authSessionState.accessToken = "synthetic";
app.preferencesState.opptyProductsCacheHours = 48;
app.productsContentState.opptyProductsByNumber.set("A", {});
app.productsContentState.productsLastCachedAt = 100;
app.consumptionTrendState.consumptionTrendCache.set("A", {});
const first = app.refreshAll.refresh("Next Quarter");
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
assert.deepEqual(calls, ["activities"]);
finish();

View File

@@ -62,6 +62,44 @@ function contentFixture(send) {
return app;
}
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
const pending = [];
let finishWrite;
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
app.activityCache.read = async () => null;
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
let complete = false, sharedComplete = false;
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
await flush();
assert.equal(pending.length, 4);
pending.slice(0, 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
await flush();
assert.equal(pending.length, 5);
assert.equal(complete, false);
assert.equal(sharedComplete, false);
pending[4]({ ok: true, actions: [], hasNext: false });
await flush();
assert.equal(complete, false, "cache write still pending");
finishWrite();
await batch;
await shared;
assert.equal(complete, true);
assert.equal(sharedComplete, true);
});
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
const pending = [];
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
await flush();
app.opportunityWorkloadActions.reset(false);
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
await batch;
assert.equal(pending.length, 4);
});
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = [];
const app = contentFixture(async message => {