Added the Refresh All button to invalidate caches and force requests to be sent again
This commit is contained in:
31
README.md
31
README.md
@@ -19,6 +19,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
||||
|
||||
## CRM Service Request Badges
|
||||
|
||||
The header's **Refresh all** button (between theme and Preferences) invalidates
|
||||
persisted Products and activity caches and clears in-memory data views. It reloads
|
||||
the selected Reference, fetching fresh products and then the permitted SR/Actions
|
||||
queries. Customer details, rate cards, consumption charts and AI summaries are
|
||||
requested again when opened. Preferences, cookies and authentication are preserved.
|
||||
The button is disabled during invalidation and list reload; product/activity badges
|
||||
continue to show subsequent background progress. Cache-clear failures are surfaced
|
||||
instead of silently reusing cached data, and stale product responses cannot restore
|
||||
an invalidated cache.
|
||||
|
||||
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
||||
@@ -37,11 +47,19 @@ Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indica
|
||||
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
||||
email and contact flags. A red badge indicates no match within that query's scope.
|
||||
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||
Badges follow Ramp Months. SR results remain in memory only. CRM SR and Actions
|
||||
refresh only after actual successful product API requests, not cache reads.
|
||||
Cache-only, empty and entirely failed cycles neither query nor clear activities.
|
||||
In mixed cycles, Actions refreshes only workloads from fresh products and CRM SR
|
||||
refreshes once. Closing and reopening the modal preserves results and does not
|
||||
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||
timestamp rather than the activity response time. Actions entries match each
|
||||
product's update; the SR snapshot matches the latest product update in the cycle.
|
||||
No credentials, request headers or debug envelopes are stored in this cache.
|
||||
Products loaded from cache restore matching activity entries without network calls.
|
||||
Missing, expired or mismatched entries are not treated as confirmed empty results;
|
||||
they wait for a real product refresh before a new activity query is allowed.
|
||||
Empty and entirely failed product cycles do not query activities. In mixed cycles,
|
||||
only fresh products permit activity API calls on cache misses; SR refreshes once.
|
||||
Activity lookups do not advance the existing Last update timestamp.
|
||||
Closing and reopening the modal preserves results and does not
|
||||
reload products or trigger these lookups. Already-running product requests may
|
||||
finish in the background and update their related activities.
|
||||
|
||||
@@ -53,7 +71,8 @@ the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
||||
aggregates all products: green means an action's `owner` matches the general
|
||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||
retain all returned actions, regardless of owner. Principal stays in memory only.
|
||||
retain all returned actions, regardless of owner. Credentials stay in memory only;
|
||||
the principal is used to scope persisted activity entries.
|
||||
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||
that load products from cache do not refresh activities.
|
||||
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
||||
|
||||
28
dist/chromium/background.js
vendored
28
dist/chromium/background.js
vendored
@@ -15,6 +15,7 @@ function createMessageTypes() {
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
@@ -1134,8 +1135,17 @@ function createProductsCache({
|
||||
storageLocalGet,
|
||||
storageLocalSet
|
||||
}) {
|
||||
let generation = 0;
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
productsCacheState.inFlightProducts.clear();
|
||||
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||
await saveProductsCache({});
|
||||
}
|
||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||
const currentGeneration = generation;
|
||||
const cache = await getProductsCache();
|
||||
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||
const cachedEntry = cache[optyNumber];
|
||||
|
||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||
@@ -1153,15 +1163,14 @@ function createProductsCache({
|
||||
})
|
||||
.then(async (items) => {
|
||||
const fetchedAt = Date.now();
|
||||
cache[optyNumber] = {
|
||||
fetchedAt,
|
||||
items
|
||||
};
|
||||
if (generation === currentGeneration) {
|
||||
cache[optyNumber] = { fetchedAt, items };
|
||||
await saveProductsCache(cache).catch(() => {});
|
||||
}
|
||||
return { items, fetchedAt };
|
||||
})
|
||||
.finally(() => {
|
||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1207,7 +1216,7 @@ function createProductsCache({
|
||||
return productsCacheState.productsCacheWritePromise;
|
||||
}
|
||||
|
||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/products.js
|
||||
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
invalidateProductsCache,
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
|
||||
310
dist/chromium/content.js
vendored
310
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
28
dist/firefox/background.js
vendored
28
dist/firefox/background.js
vendored
@@ -15,6 +15,7 @@ function createMessageTypes() {
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
@@ -1134,8 +1135,17 @@ function createProductsCache({
|
||||
storageLocalGet,
|
||||
storageLocalSet
|
||||
}) {
|
||||
let generation = 0;
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
productsCacheState.inFlightProducts.clear();
|
||||
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||
await saveProductsCache({});
|
||||
}
|
||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||
const currentGeneration = generation;
|
||||
const cache = await getProductsCache();
|
||||
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||
const cachedEntry = cache[optyNumber];
|
||||
|
||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||
@@ -1153,15 +1163,14 @@ function createProductsCache({
|
||||
})
|
||||
.then(async (items) => {
|
||||
const fetchedAt = Date.now();
|
||||
cache[optyNumber] = {
|
||||
fetchedAt,
|
||||
items
|
||||
};
|
||||
if (generation === currentGeneration) {
|
||||
cache[optyNumber] = { fetchedAt, items };
|
||||
await saveProductsCache(cache).catch(() => {});
|
||||
}
|
||||
return { items, fetchedAt };
|
||||
})
|
||||
.finally(() => {
|
||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1207,7 +1216,7 @@ function createProductsCache({
|
||||
return productsCacheState.productsCacheWritePromise;
|
||||
}
|
||||
|
||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/products.js
|
||||
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
invalidateProductsCache,
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
|
||||
310
dist/firefox/content.js
vendored
310
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -33,6 +33,9 @@ module.exports = {
|
||||
"src/services/fusion/crm-service-requests.js",
|
||||
"src/features/opportunities/crm-service-requests.js",
|
||||
"src/features/opportunities/product-activities.js",
|
||||
"src/features/opportunities/refresh-all.js",
|
||||
"src/features/opportunities/activity-cache.js",
|
||||
"src/features/opportunities/activity-progress.js",
|
||||
"src/features/opportunities/crm-sr-popover.js",
|
||||
"src/features/opportunities/workload-actions.js",
|
||||
"src/features/opportunities/fiscal-periods.js",
|
||||
|
||||
@@ -123,7 +123,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
invalidateProductsCache,
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -20,6 +21,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
getXsrfTokenCookie()
|
||||
.then((result) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ function createContentApplication(overrides = {}) {
|
||||
const opportunitiesTableState = createOpportunitiesTableState();
|
||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||
const preferencesState = createPreferencesState(contracts);
|
||||
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||
const productsContentState = createProductsContentState();
|
||||
const aiSummaryState = createAiSummaryState();
|
||||
const consumptionPlansState = createConsumptionPlansState();
|
||||
@@ -89,6 +90,7 @@ function createContentApplication(overrides = {}) {
|
||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||
debugState,
|
||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||
@@ -218,6 +220,8 @@ function createContentApplication(overrides = {}) {
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||
});
|
||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||
closePopover: (...args) => crmSrPopover.close(...args),
|
||||
@@ -325,6 +329,29 @@ function createContentApplication(overrides = {}) {
|
||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||
environment
|
||||
});
|
||||
const refreshAll = createRefreshAll({
|
||||
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
resetRelatedData: () => {
|
||||
opportunityCrmServiceRequests.reset();
|
||||
opportunityWorkloadActions.reset();
|
||||
consumptionPlansView.closeAccountPlansModal();
|
||||
installBaseView.closeInstallBaseModal();
|
||||
serviceRequestsView.closeServiceRequestsModal();
|
||||
installBaseAction.resetRequests();
|
||||
serviceRequestsAction.resetRequests();
|
||||
ratecardController.resetRequests();
|
||||
consumptionPlanDetailController.resetRequests();
|
||||
productsContentJobs.renderProductsProgress();
|
||||
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||
}
|
||||
});
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||
const productActivities = createProductActivities({
|
||||
productsContentState, opportunitiesQueryState,
|
||||
resetCrm: () => opportunityCrmServiceRequests.reset(),
|
||||
@@ -347,6 +374,8 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||
activityCache,
|
||||
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
authSessionState,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
@@ -557,6 +586,9 @@ function createContentApplication(overrides = {}) {
|
||||
preferencesView,
|
||||
productsContentJobs,
|
||||
productActivities,
|
||||
activityProgress,
|
||||
refreshAll,
|
||||
activityCache,
|
||||
productsDetails,
|
||||
aiSummaryContent,
|
||||
consumptionPlansAction,
|
||||
|
||||
@@ -70,5 +70,20 @@ function createContentIcons({
|
||||
return svg;
|
||||
}
|
||||
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon };
|
||||
function createRefreshIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function createContentModal({
|
||||
closeOwnerFilterPanel,
|
||||
closePreferencesModal,
|
||||
createSettingsIcon,
|
||||
createRefreshAllButton,
|
||||
debugState,
|
||||
ensureExtensionStyles,
|
||||
getCustomerOptions,
|
||||
@@ -108,7 +109,6 @@ function createContentModal({
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
||||
|
||||
const shell = document.createElement("section");
|
||||
shell.className = "opportunities-extension-shell";
|
||||
@@ -120,10 +120,6 @@ function createContentModal({
|
||||
title.id = "opportunities-extension-title";
|
||||
title.textContent = "Opportunities Extension";
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.id = "opportunities-extension-subtitle";
|
||||
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
|
||||
|
||||
const authBadge = document.createElement("span");
|
||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||
@@ -143,9 +139,10 @@ function createContentModal({
|
||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||
productsCacheTimestamp.setAttribute("role", "status");
|
||||
productsCacheTimestamp.hidden = true;
|
||||
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||
|
||||
const titleBlock = document.createElement("div");
|
||||
titleBlock.className = "opportunities-extension-title-row";
|
||||
titleBlock.append(title, subtitleRow);
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
@@ -174,7 +171,7 @@ function createContentModal({
|
||||
|
||||
const headerActions = document.createElement("div");
|
||||
headerActions.className = "opportunities-extension-header-actions";
|
||||
headerActions.append(themeButton, preferencesButton, closeButton);
|
||||
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
|
||||
|
||||
header.append(titleBlock, headerActions);
|
||||
|
||||
|
||||
@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
||||
current.promise = (async () => {
|
||||
try {
|
||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
|
||||
if (!pending.has(subPlanNum)) {
|
||||
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
|
||||
pending.set(subPlanNum, promise);
|
||||
}
|
||||
const items = await pending.get(subPlanNum);
|
||||
if (!section.isConnected || states.get(section) !== current) return;
|
||||
current.status = "ready";
|
||||
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
||||
})();
|
||||
return current.promise;
|
||||
}
|
||||
return { createPlanDetailSection, loadPlanDetailForPanel };
|
||||
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
|
||||
return;
|
||||
}
|
||||
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
||||
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||
currentRequests.set(optyNumber, { status: "loading" });
|
||||
openAccountPlansModal(opportunity, [], "loading");
|
||||
renderOpportunitiesTable();
|
||||
requestConsumerTokenServiceOnce()
|
||||
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
|
||||
authorizations
|
||||
}))
|
||||
.then((response) => {
|
||||
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
|
||||
renderOpportunitiesTable();
|
||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||
}).catch((error) => {
|
||||
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||
status: "error",
|
||||
|
||||
@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
||||
try {
|
||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||
if (!pending.has(partyNumber)) {
|
||||
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
|
||||
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||
pending.set(partyNumber, request);
|
||||
}
|
||||
view.render(await pending.get(partyNumber));
|
||||
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { createInstallBaseButton };
|
||||
return { createInstallBaseButton, resetRequests: () => pending.clear() };
|
||||
}
|
||||
|
||||
52
src/features/opportunities/activity-cache.js
Normal file
52
src/features/opportunities/activity-cache.js
Normal file
@@ -0,0 +1,52 @@
|
||||
function createActivityCache({ authSessionState, preferencesState, environment }) {
|
||||
const { browser, chrome } = environment;
|
||||
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
|
||||
async function invalidate() {
|
||||
await storage("set", { [invalidatedKey]: Date.now() });
|
||||
const entries = await storage("get", null);
|
||||
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
|
||||
if (keys.length) await storage("remove", keys);
|
||||
}
|
||||
function storage(method, argument) {
|
||||
if (browser?.storage?.local) return browser.storage.local[method](argument);
|
||||
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local[method](argument, result => {
|
||||
const error = chrome.runtime?.lastError;
|
||||
if (error) reject(new Error(error.message));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
function context(kind, id, timestamp) {
|
||||
const principal = authSessionState.principal;
|
||||
const fetchedAt = Number(timestamp);
|
||||
const hours = Number(preferencesState.opptyProductsCacheHours);
|
||||
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
|
||||
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
|
||||
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify([principal, kind, String(id)])}`, fetchedAt };
|
||||
}
|
||||
async function read(kind, id, timestamp) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope) return null;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
|
||||
const result = await storage("get", scope.key);
|
||||
const entry = result?.[scope.key];
|
||||
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
|
||||
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||
&& typeof entry.hasNext === "boolean" ? entry : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function write(kind, id, timestamp, items, hasNext = false) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope || !Array.isArray(items)) return;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
|
||||
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
|
||||
} catch { /* Storage failure must not discard successfully fetched data. */ }
|
||||
}
|
||||
return { invalidate, read, write };
|
||||
}
|
||||
38
src/features/opportunities/activity-progress.js
Normal file
38
src/features/opportunities/activity-progress.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function createActivityProgress({ MODAL_ID, environment }) {
|
||||
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
const entries = new Map();
|
||||
function update(key, state, completed = 0, total = null) {
|
||||
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
|
||||
if (!host) return;
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-products-progress";
|
||||
badge.setAttribute("role", "status");
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
host.append(badge);
|
||||
entry = { badge, timer: null, hideTimer: null, state: "idle" };
|
||||
entries.set(key, entry);
|
||||
}
|
||||
if (state === "complete" && entry.state === "complete") return;
|
||||
clearTimeout(entry.timer);
|
||||
clearTimeout(entry.hideTimer);
|
||||
entry.state = state;
|
||||
const label = key === "sr" ? "Service Requests" : "Actions";
|
||||
entry.badge.hidden = state === "idle";
|
||||
entry.badge.setAttribute("data-state", state);
|
||||
entry.badge.setAttribute("data-fading", "false");
|
||||
entry.badge.setAttribute("aria-busy", String(state === "running"));
|
||||
const count = total === null ? String(completed) : `${completed}/${total}`;
|
||||
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
|
||||
}, 5000);
|
||||
}
|
||||
return { update };
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, environment }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
||||
function load(token, version) {
|
||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); }
|
||||
function load(token, version, cacheOptions = null) {
|
||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||
if (!pending) pending = run(token, version, generation);
|
||||
if (!pending) pending = run(token, version, generation, cacheOptions);
|
||||
return pending;
|
||||
}
|
||||
async function run(token, version, currentGeneration) {
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
const isActive = () => currentGeneration === generation;
|
||||
status = "loading";
|
||||
try {
|
||||
const items = await requestCrmServiceRequest(token, version, isActive);
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return;
|
||||
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; }
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
reportProgress("running", 0);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||
if (isActive()) reportProgress("running", count, total);
|
||||
});
|
||||
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||
}
|
||||
if (!items || !isActive()) return;
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
@@ -25,8 +35,9 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
} catch {
|
||||
if (isActive()) status = "error";
|
||||
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||
}
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
|
||||
@@ -9,7 +9,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
if (!current || current.completed || current.requestId !== requestId
|
||||
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||
current.completed = true;
|
||||
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error && !result.fromCache);
|
||||
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
|
||||
if (!products.length) return;
|
||||
resetCrm();
|
||||
const seen = new Set();
|
||||
@@ -20,9 +20,10 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
loadActions({ ...result, items }, true);
|
||||
loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true });
|
||||
}
|
||||
Promise.resolve(loadCrm(current.token, current.version)).finally(() => {
|
||||
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||
Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).finally(() => {
|
||||
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
50
src/features/opportunities/refresh-all.js
Normal file
50
src/features/opportunities/refresh-all.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
let pending = null;
|
||||
function refresh(period) {
|
||||
if (pending) return pending;
|
||||
opportunitiesQueryState.periodRequestVersion += 1;
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsLastCachedAt = 0;
|
||||
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||
resetRelatedData();
|
||||
pending = (async () => {
|
||||
await activityCache.invalidate();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
|
||||
await reload(period);
|
||||
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
function createButton(getPeriod) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
|
||||
button.append(createRefreshIcon());
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-label", "Refresh all");
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
button.title = "Refreshing...";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
try { await refresh(getPeriod()); }
|
||||
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
|
||||
finally {
|
||||
button.disabled = false;
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-busy", "false");
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { refresh, createButton };
|
||||
}
|
||||
@@ -311,3 +311,41 @@
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
||||
.opportunities-extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.opportunities-extension-title-row h1 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-height: 22px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
|
||||
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
|
||||
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
|
||||
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, environment }) {
|
||||
let session = newSession();
|
||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
|
||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||
function updateProgress(current) {
|
||||
if (current !== session || !current.enabled || !current.progress.size) return;
|
||||
const records = [...current.progress];
|
||||
const completed = records.filter(record => record.status !== "loading").length;
|
||||
const failed = records.filter(record => record.status === "error").length;
|
||||
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||
}
|
||||
function reset(enabled = true) {
|
||||
session.enabled = false;
|
||||
session.queue.length = 0;
|
||||
session = newSession();
|
||||
session.enabled = enabled;
|
||||
reportProgress("idle");
|
||||
}
|
||||
function idsFor(products) {
|
||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
||||
}
|
||||
function loadProducts(products, refresh = false) {
|
||||
function loadProducts(products, refresh = false, cacheOptions = null) {
|
||||
if (!session.enabled || !allowsCrmActivities()) return;
|
||||
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
|
||||
for (const revnId of idsFor(products)) {
|
||||
if (!refresh && session.records.has(revnId)) continue;
|
||||
session.progress.delete(session.records.get(revnId));
|
||||
const record = { status: "loading", actions: [], hasNext: false };
|
||||
session.records.set(revnId, record);
|
||||
session.queue.push({ revnId, record });
|
||||
if (!cacheOptions) session.progress.add(record);
|
||||
session.queue.push({ revnId, record, cacheOptions });
|
||||
}
|
||||
updateProgress(session);
|
||||
pump(session);
|
||||
}
|
||||
function reopen() {
|
||||
@@ -33,23 +45,35 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||
}
|
||||
}
|
||||
async function run(current, { revnId, record }) {
|
||||
async function run(current, { revnId, record, cacheOptions }) {
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
if (cached) {
|
||||
record.actions = cached.items;
|
||||
record.hasNext = cached.hasNext;
|
||||
record.status = "ready";
|
||||
return;
|
||||
}
|
||||
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
|
||||
current.progress.add(record);
|
||||
updateProgress(current);
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
appendBackgroundRequestLog(result?.debugRequests);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||
record.actions = result.actions;
|
||||
record.hasNext = result.hasNext;
|
||||
record.status = "ready";
|
||||
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||
} catch (error) {
|
||||
if (!current.enabled || current !== session) return;
|
||||
record.status = "error";
|
||||
record.error = error.message || "Unable to load workload actions.";
|
||||
} finally {
|
||||
if (current.enabled && current === session) queueProductsTableRender();
|
||||
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||
}
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
|
||||
@@ -5,8 +5,17 @@ function createProductsCache({
|
||||
storageLocalGet,
|
||||
storageLocalSet
|
||||
}) {
|
||||
let generation = 0;
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
productsCacheState.inFlightProducts.clear();
|
||||
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||
await saveProductsCache({});
|
||||
}
|
||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||
const currentGeneration = generation;
|
||||
const cache = await getProductsCache();
|
||||
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||
const cachedEntry = cache[optyNumber];
|
||||
|
||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||
@@ -24,15 +33,14 @@ function createProductsCache({
|
||||
})
|
||||
.then(async (items) => {
|
||||
const fetchedAt = Date.now();
|
||||
cache[optyNumber] = {
|
||||
fetchedAt,
|
||||
items
|
||||
};
|
||||
if (generation === currentGeneration) {
|
||||
cache[optyNumber] = { fetchedAt, items };
|
||||
await saveProductsCache(cache).catch(() => {});
|
||||
}
|
||||
return { items, fetchedAt };
|
||||
})
|
||||
.finally(() => {
|
||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,5 +86,5 @@ function createProductsCache({
|
||||
return productsCacheState.productsCacheWritePromise;
|
||||
}
|
||||
|
||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
||||
try {
|
||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
||||
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
|
||||
if (!pending.has(planNumber)) {
|
||||
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
|
||||
pending.set(planNumber, promise);
|
||||
}
|
||||
handle.showItems(await pending.get(planNumber));
|
||||
} catch (error) {
|
||||
handle.showError(error.message || "Unable to load rate card.");
|
||||
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
||||
button.addEventListener("click", () => showRatecard(plan, button));
|
||||
return button;
|
||||
}
|
||||
return { createRatecardButton, showRatecard };
|
||||
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
||||
try {
|
||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||
if (!pending.has(partyNumber)) {
|
||||
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
|
||||
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||
pending.set(partyNumber, request);
|
||||
}
|
||||
view.render(await pending.get(partyNumber));
|
||||
} catch (error) {
|
||||
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { createServiceRequestsButton };
|
||||
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
|
||||
};
|
||||
}
|
||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
|
||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
while (isActive()) {
|
||||
@@ -41,6 +41,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
||||
}
|
||||
if (!isActive()) return null;
|
||||
items.push(...data.items);
|
||||
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||
if (!data.hasMore) return items;
|
||||
const received = data.count === undefined ? data.items.length : Number(data.count);
|
||||
const current = data.offset === undefined ? offset : Number(data.offset);
|
||||
|
||||
@@ -12,6 +12,7 @@ function createMessageTypes() {
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||
|
||||
92
tests/activityCache.test.cjs
Normal file
92
tests/activityCache.test.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||
const local = {
|
||||
get: async key => ({ [key]: saved[key] }),
|
||||
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values)))
|
||||
};
|
||||
const api = family === "firefox" ? { browser: { storage: { local } } } : {
|
||||
chrome: { runtime: {}, storage: { local: {
|
||||
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done)
|
||||
} } }
|
||||
};
|
||||
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } });
|
||||
app.authSessionState.principal = principal;
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.preferencesState.opptyProductsCacheHours = 24;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
app.activityProgress.update = () => {};
|
||||
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-token" } });
|
||||
const calls = [];
|
||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||
calls.push("sr");
|
||||
return [{ SrNumber: "SR1", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||
};
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => {
|
||||
calls.push("actions");
|
||||
return { ok: true, actions: [{ owner: principal, name: "Test action" }], hasNext: false };
|
||||
};
|
||||
async function load(cachedAt, cacheOnly) {
|
||||
app.opportunityCrmServiceRequests.reset();
|
||||
await app.opportunityCrmServiceRequests.load("synthetic-token", 1, { cachedAt, cacheOnly });
|
||||
const products = { items: [{ RevnId: "123" }], cachedAt, fromCache: cacheOnly };
|
||||
app.productsContentState.opptyProductsByNumber.set("OP1", products);
|
||||
app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt, cacheOnly });
|
||||
await flush();
|
||||
}
|
||||
return { app, calls, load, saved, local };
|
||||
}
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
|
||||
const h = setup(family);
|
||||
const stamp = Date.now() - 1000;
|
||||
await h.load(stamp, false);
|
||||
assert.deepEqual(h.calls, ["sr", "actions"]);
|
||||
assert.equal(Object.keys(h.saved).length, 2);
|
||||
Object.values(h.saved).forEach(entry => assert.equal(entry.fetchedAt, stamp));
|
||||
assert.doesNotMatch(JSON.stringify(h.saved), /synthetic-token|debugRequests|authorization/i);
|
||||
const restored = setup(family, h.saved);
|
||||
await restored.load(stamp, true);
|
||||
assert.deepEqual(restored.calls, []);
|
||||
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (4h)");
|
||||
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "\u2705 Act");
|
||||
await restored.load(stamp, false);
|
||||
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
|
||||
const other = setup(family, h.saved, "other@example.invalid");
|
||||
await other.load(stamp, true);
|
||||
assert.deepEqual(other.calls, []);
|
||||
assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null);
|
||||
assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "Act ?");
|
||||
await restored.load(stamp + 1, false);
|
||||
assert.deepEqual(restored.calls, ["sr", "actions"]);
|
||||
});
|
||||
}
|
||||
test("TTL follows product preferences, rejects stale/mismatched cache and stores empty success", async () => {
|
||||
const h = setup("firefox");
|
||||
const stamp = Date.now() - 2 * 3600000;
|
||||
await h.app.activityCache.write("sr", "all", stamp, []);
|
||||
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||
h.app.preferencesState.opptyProductsCacheHours = 1;
|
||||
assert.equal(await h.app.activityCache.read("sr", "all", stamp), null);
|
||||
h.app.preferencesState.opptyProductsCacheHours = 24;
|
||||
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||
assert.equal(await h.app.activityCache.read("sr", "all", Date.now() + 100000), null);
|
||||
await h.load(stamp + 1, true);
|
||||
assert.deepEqual(h.calls, []);
|
||||
});
|
||||
test("storage failure does not lose live activity results and failures are never cached", async () => {
|
||||
const h = setup("firefox");
|
||||
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
|
||||
h.local.set = async () => { throw new Error("Synthetic storage write failure"); };
|
||||
await h.load(Date.now() - 1000, false);
|
||||
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "\u2705 SR (4h)");
|
||||
const failed = setup("firefox");
|
||||
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
|
||||
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });
|
||||
await failed.load(Date.now() - 1000, false);
|
||||
assert.deepEqual(failed.saved, {});
|
||||
});
|
||||
62
tests/activityProgress.test.cjs
Normal file
62
tests/activityProgress.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
test("activity indicators share Products styles, fade after 5s, retain failures and cancel stale timers", () => {
|
||||
const nodes = [];
|
||||
const timers = new Map();
|
||||
let id = 0;
|
||||
const app = loadApplication("content", {
|
||||
document: { querySelector: () => ({ append: node => nodes.push(node) }), createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) },
|
||||
setTimeout: (fn, delay) => { timers.set(++id, { fn, delay }); return id; }, clearTimeout: key => timers.delete(key)
|
||||
});
|
||||
const update = app.activityProgress.update;
|
||||
update("sr", "running", 15, 58);
|
||||
assert.equal(nodes[0].textContent, "Service Requests 15/58");
|
||||
assert.equal(nodes[0].attributes["data-state"], "running");
|
||||
assert.equal(nodes[0].className, "opportunities-extension-products-progress");
|
||||
update("sr", "complete", 58, 58);
|
||||
assert.equal([...timers.values()][0].delay, 5000);
|
||||
[...timers.values()][0].fn();
|
||||
assert.equal(nodes[0].attributes["data-fading"], "true");
|
||||
const hide = [...timers.values()].find(timer => timer.delay === 300);
|
||||
hide.fn();
|
||||
assert.equal(nodes[0].hidden, true);
|
||||
update("sr", "running", 0);
|
||||
assert.equal(nodes[0].hidden, false);
|
||||
assert.equal(timers.size, 0);
|
||||
update("actions", "complete-with-errors", 2, 3);
|
||||
assert.equal(nodes[1].hidden, false);
|
||||
assert.equal(timers.size, 0);
|
||||
update("sr", "error");
|
||||
assert.equal(nodes[0].textContent, "Service Requests unavailable");
|
||||
});
|
||||
|
||||
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {
|
||||
const app = loadApplication("content");
|
||||
const progress = [];
|
||||
app.activityProgress.update = (...args) => progress.push(args);
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async (_token, _version, _active, onProgress) => {
|
||||
onProgress(15, 20);
|
||||
onProgress(20, 20);
|
||||
return Array.from({ length: 20 }, () => ({}));
|
||||
};
|
||||
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||
assert.deepEqual(progress, [["sr", "running", 0], ["sr", "running", 15, 20], ["sr", "running", 20, 20], ["sr", "complete", 20, 20]]);
|
||||
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({});
|
||||
const pending = [];
|
||||
app.platformContentMessages.sendRuntimeMessage = () => new Promise(resolve => pending.push(resolve));
|
||||
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "1" }, { RevnId: "2" }] });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(progress.at(-1), ["actions", "running", 0, 2]);
|
||||
pending[0]({ ok: true, actions: [], hasNext: false });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
|
||||
pending[1]({ ok: false, error: "Synthetic failure" });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
|
||||
});
|
||||
@@ -210,7 +210,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.match(css, /flex: 0 0 24px/);
|
||||
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
||||
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "");
|
||||
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
|
||||
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "");
|
||||
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
|
||||
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
|
||||
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
||||
|
||||
@@ -12,8 +12,8 @@ function setup() {
|
||||
const calls = [];
|
||||
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
|
||||
app.opportunityCrmServiceRequests.load = async token => calls.push(`SR ${token}`);
|
||||
app.opportunityWorkloadActions.loadProducts = products => calls.push(products.items[0]?.RevnId || "empty products");
|
||||
app.opportunityCrmServiceRequests.load = async (token, version, options) => { if (!options?.cacheOnly) calls.push(`SR ${token}`); };
|
||||
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||
function start(token = "token") {
|
||||
@@ -69,19 +69,19 @@ test("partial failures use successful products only; closed, failed and empty cy
|
||||
assert.equal(h.calls.includes("closed"), false);
|
||||
});
|
||||
|
||||
test("cached products never trigger or reset activities; mixed cycles use only fresh products", async () => {
|
||||
test("cached products restore activities without network; mixed cycles fetch only fresh products", async () => {
|
||||
const h = setup();
|
||||
const cached = h.start();
|
||||
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||
h.progress(cached, { state: "complete" });
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, []);
|
||||
assert.deepEqual(h.calls, ["reset SR"]);
|
||||
const mixed = h.start();
|
||||
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||
h.progress(mixed, { state: "complete" });
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, ["reset SR", "fresh", "SR token"]);
|
||||
assert.deepEqual(h.calls, ["reset SR", "reset SR", "fresh", "SR token"]);
|
||||
});
|
||||
|
||||
test("reopening an existing modal does not reload products or reset/query activities", async () => {
|
||||
|
||||
104
tests/refreshAll.test.cjs
Normal file
104
tests/refreshAll.test.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: invalidation detaches old product flights and prevents stale cache restoration`, async () => {
|
||||
const pending = [];
|
||||
const storage = { preferences: { keep: true } };
|
||||
const app = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
|
||||
const message = { type: "opportunitiesExtension.requestOpptyProducts", accessToken: "synthetic", optyNumbers: ["A"], cacheMaxAgeMs: 86400000 };
|
||||
const old = app.dispatch({ ...message, requestId: "old" });
|
||||
await flush();
|
||||
assert.equal(pending.length, 1);
|
||||
const clear = await app.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" });
|
||||
assert.equal(clear.ok, true);
|
||||
const fresh = app.dispatch({ ...message, requestId: "fresh" });
|
||||
await flush();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[1](response({ items: [{ RevnId: "new" }], count: 1, hasMore: false }));
|
||||
await fresh;
|
||||
pending[0](response({ items: [{ RevnId: "old" }], count: 1, hasMore: false }));
|
||||
await old;
|
||||
await app.dispatch({ ...message, requestId: "cached" });
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(storage["opportunitiesExtension.opptyProductsCache.v1"].A.items[0].RevnId, "new");
|
||||
assert.deepEqual(storage.preferences, { keep: true });
|
||||
});
|
||||
}
|
||||
|
||||
test("activity invalidation preserves unrelated storage and rejects late entries from old product timestamps", async () => {
|
||||
const values = { preferences: "keep" };
|
||||
const app = loadApplication("content", { browser: { storage: { local: {
|
||||
get: async key => key === null ? { ...values } : { [key]: values[key] },
|
||||
set: async data => Object.assign(values, data),
|
||||
remove: async keys => keys.forEach(key => delete values[key])
|
||||
} } } });
|
||||
app.authSessionState.principal = "user@example.invalid";
|
||||
const stamp = Date.now() - 1000;
|
||||
await app.activityCache.write("sr", "all", stamp, []);
|
||||
const oldKey = Object.keys(values).find(key => key.includes("activities.v1:"));
|
||||
const oldEntry = values[oldKey];
|
||||
await app.activityCache.invalidate();
|
||||
assert.equal(values[oldKey], undefined);
|
||||
assert.equal(values.preferences, "keep");
|
||||
values[oldKey] = oldEntry;
|
||||
assert.equal(await app.activityCache.read("sr", "all", stamp), null);
|
||||
delete values[oldKey];
|
||||
await app.activityCache.write("sr", "all", stamp, []);
|
||||
assert.equal(values[oldKey], undefined);
|
||||
});
|
||||
|
||||
function content() {
|
||||
const app = loadApplication("content", { document: { createElement: () => ({ children: [], append(node) { this.children.push(node); }, listeners: {}, attributes: {}, addEventListener(key, fn) { this.listeners[key] = fn; }, setAttribute(key, value) { this.attributes[key] = value; } }) } });
|
||||
app.contentIcons.createRefreshIcon = () => ({ tag: "svg" });
|
||||
for (const [object, method] of [
|
||||
["opportunityCrmServiceRequests", "reset"], ["opportunityWorkloadActions", "reset"],
|
||||
["consumptionPlansView", "closeAccountPlansModal"], ["installBaseView", "closeInstallBaseModal"],
|
||||
["serviceRequestsView", "closeServiceRequestsModal"], ["productsContentJobs", "renderProductsProgress"],
|
||||
["opportunitiesTable", "setOpportunitiesTableState"]
|
||||
]) app[object][method] = () => {};
|
||||
return app;
|
||||
}
|
||||
test("Refresh all deduplicates clicks, clears memory before reload and preserves identity/preferences", async () => {
|
||||
const app = content();
|
||||
const calls = [];
|
||||
let finish;
|
||||
app.activityCache.invalidate = () => { calls.push("activities"); return new Promise(resolve => { finish = resolve; }); };
|
||||
app.platformContentMessages.sendRuntimeMessage = async message => { calls.push(message.type); return { ok: true }; };
|
||||
app.opportunitiesController.requestOpportunitiesForPeriod = async period => calls.push(period);
|
||||
app.authSessionState.accessToken = "synthetic";
|
||||
app.preferencesState.opptyProductsCacheHours = 48;
|
||||
app.productsContentState.opptyProductsByNumber.set("A", {});
|
||||
app.consumptionTrendState.consumptionTrendCache.set("A", {});
|
||||
const first = app.refreshAll.refresh("Next Quarter");
|
||||
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
|
||||
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
|
||||
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
|
||||
assert.deepEqual(calls, ["activities"]);
|
||||
finish();
|
||||
await first;
|
||||
assert.deepEqual(calls, ["activities", "opportunitiesExtension.invalidateProductsCache", "Next Quarter"]);
|
||||
assert.equal(app.authSessionState.accessToken, "synthetic");
|
||||
assert.equal(app.preferencesState.opptyProductsCacheHours, 48);
|
||||
});
|
||||
test("Refresh all reports failures, does not reload on failed invalidation, and re-enables the button", async () => {
|
||||
const app = content();
|
||||
app.activityCache.invalidate = async () => { throw new Error("Synthetic storage failure"); };
|
||||
let notified = "";
|
||||
app.opportunitiesLinks.showCopyConfirmation = message => { notified = message; };
|
||||
app.opportunitiesController.requestOpportunitiesForPeriod = () => assert.fail("must not reload");
|
||||
const button = app.refreshAll.createButton(() => "Current Quarter");
|
||||
const request = button.listeners.click();
|
||||
assert.equal(button.disabled, true);
|
||||
await request;
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(button.title, "Refresh all");
|
||||
assert.equal(button.attributes["aria-label"], "Refresh all");
|
||||
assert.equal(button.children.length, 1);
|
||||
assert.equal(button.children[0].tag, "svg");
|
||||
assert.equal(button.attributes["aria-busy"], "false");
|
||||
assert.equal(notified, "Synthetic storage failure");
|
||||
});
|
||||
Reference in New Issue
Block a user