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 Preferences lifetime as Products (24 hours by default), anchored to the product
timestamp rather than the activity response time. Actions entries match each 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. 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. No credentials, request headers or debug envelopes are stored in this cache.
Products loaded from cache restore matching activity entries without network calls. Products loaded from cache restore matching activity entries without network calls.
Missing, expired or mismatched entries are not treated as confirmed empty results; 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 ## 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, Workload Actions are queried in the background after the product cycle completes,
using only fresh API results. Each distinct `RevnId` uses `requestWlActions` with using only fresh API results. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR 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/refresh-all.js",
"src/features/opportunities/activity-cache.js", "src/features/opportunities/activity-cache.js",
"src/features/opportunities/activity-progress.js", "src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js", "src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js", "src/features/opportunities/workload-actions.js",
"src/features/opportunities/fiscal-periods.js", "src/features/opportunities/fiscal-periods.js",

View File

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

View File

@@ -33,6 +33,7 @@ function createContentModal({
closePreferencesModal, closePreferencesModal,
createSettingsIcon, createSettingsIcon,
createRefreshAllButton, createRefreshAllButton,
createActivityFilterControls,
debugState, debugState,
ensureExtensionStyles, ensureExtensionStyles,
getCustomerOptions, getCustomerOptions,
@@ -380,7 +381,8 @@ function createContentModal({
statusFilter.append(statusFilterLabel, statusFilters); statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault()); 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); body.append(form);
const stageDashboard = document.createElement("section"); const stageDashboard = document.createElement("section");

View File

@@ -222,7 +222,14 @@ function createDebugContentView({
method.className = "opportunities-extension-debug-method"; method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET"; method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase()); 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); heading.append(name, summaryMeta);

View File

@@ -214,3 +214,10 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline-color: #8cc8d3; 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 hours = Number(preferencesState.opptyProductsCacheHours);
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000; 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; 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) { async function read(kind, id, timestamp) {
const scope = context(kind, id, timestamp); const scope = context(kind, id, timestamp);
@@ -33,7 +37,11 @@ function createActivityCache({ authSessionState, preferencesState, environment }
const invalidated = await storage("get", invalidatedKey); const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null; if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
const result = await storage("get", scope.key); 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) return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item)) && entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
&& typeof entry.hasNext === "boolean" ? entry : null; && typeof entry.hasNext === "boolean" ? entry : null;
@@ -46,6 +54,13 @@ function createActivityCache({ authSessionState, preferencesState, environment }
const invalidated = await storage("get", invalidatedKey); const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return; if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } }); 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. */ } } catch { /* Storage failure must not discard successfully fetched data. */ }
} }
return { invalidate, read, write }; 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, failed: 0,
cacheHits: 0 cacheHits: 0
}; };
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map(); consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map(); consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map(); consumptionTrendState.consumptionTrendWindowOffsets = new Map();

View File

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

View File

@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
renderOpportunitiesTable, renderOpportunitiesTable,
saveFilterPreferences, saveFilterPreferences,
stringifySearchValue, stringifySearchValue,
matchesActivities,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
@@ -340,7 +341,8 @@ function createOpportunitiesFilters({
&& matchesSelectedStatus(item.StatusCode) && matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)) && opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(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); 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); const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return; if (!products.length) return;
resetCrm(); resetCrm();
const pending = [];
const seen = new Set(); const seen = new Set();
for (const result of products) { for (const result of products) {
const items = result.items.filter(item => { const items = result.items.filter(item => {
@@ -20,11 +21,15 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id); seen.add(id);
return true; 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)); 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(() => { pending.push(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) }));
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render(); return Promise.allSettled(pending).then(() => {
if (cycle !== current || productsContentState.currentProductsRequestId !== requestId
|| opportunitiesQueryState.periodRequestVersion !== current.version) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => {}); }).catch(() => {});
} }
return { begin, complete }; return { begin, complete };

View File

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

View File

@@ -31,14 +31,15 @@
.opportunities-extension-form { .opportunities-extension-form {
display: grid; 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; align-items: end;
gap: 12px; gap: 12px;
min-height: 0; min-height: 0;
padding: 12px; padding: 12px;
} }
.opportunities-extension-form > * { .opportunities-extension-form > *,
.opportunities-extension-badge-filters > section {
min-width: 0; min-width: 0;
width: 100%; width: 100%;
max-width: none; max-width: none;
@@ -47,7 +48,8 @@
font-size: 12px; 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; font-size: 12px;
line-height: 16px; line-height: 16px;
} }
@@ -97,22 +99,12 @@
white-space: nowrap; 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) { @media (max-width: 1150px) {
.opportunities-extension-form { .opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.opportunities-extension-form > .opportunities-extension-status-filter, .opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { .opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-column: span 2; grid-column: span 2;
} }
} }
@@ -125,16 +117,73 @@
.opportunities-extension-form .opportunities-extension-status-filter-controls { .opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: wrap; flex-wrap: wrap;
} }
.opportunities-extension-form > .opportunities-extension-status-filter, .opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { .opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-template-rows: auto auto; grid-template-rows: auto auto;
} }
} }
@media (max-width: 420px) { @media (max-width: 420px) {
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); } .opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
.opportunities-extension-form > .opportunities-extension-status-filter, .opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; } .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. */ /* Keep layout rules on the outer table, never on expanded product tables. */

View File

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

View File

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

View File

@@ -4,12 +4,14 @@ const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve)); const flush = () => new Promise(resolve => setImmediate(resolve));
function setup(family, saved = {}, principal = "user@example.invalid") { function setup(family, saved = {}, principal = "user@example.invalid") {
const local = { const local = {
get: async key => ({ [key]: saved[key] }), get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))) 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 } } } : { const api = family === "firefox" ? { browser: { storage: { local } } } : {
chrome: { runtime: {}, 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() {} }) } }); 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 }; return { app, calls, load, saved, local };
} }
for (const family of ["chrome", "firefox"]) { 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 () => { test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
const h = setup(family); const h = setup(family);
const stamp = Date.now() - 1000; 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); await h.load(stamp + 1, true);
assert.deepEqual(h.calls, []); 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 () => { test("storage failure does not lose live activity results and failures are never cached", async () => {
const h = setup("firefox"); const h = setup("firefox");
h.local.get = async () => { throw new Error("Synthetic storage read failure"); }; 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 }; 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", () => { test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
const h = fixture(); const h = fixture();
const background = loadApplication("background"); 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); 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"); 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, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) 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\)\);/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
assert.match(css, /@media \(max-width: 1150px\)/); 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 { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs"); const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve)); 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() { function setup() {
let listener; let listener;

View File

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

View File

@@ -62,6 +62,44 @@ function contentFixture(send) {
return app; 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 () => { test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = []; const calls = [];
const app = contentFixture(async message => { const app = contentFixture(async message => {