Implemented filters for Service Requests and Actions
This commit is contained in:
12
README.md
12
README.md
@@ -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
|
||||||
|
|||||||
201
dist/chromium/content.js
vendored
201
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
201
dist/firefox/content.js
vendored
201
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
83
src/features/opportunities/activity-filters.js
Normal file
83
src/features/opportunities/activity-filters.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"); };
|
||||||
|
|||||||
201
tests/activityFilters.test.cjs
Normal file
201
tests/activityFilters.test.cjs
Normal 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;/);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user