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
|
## 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`
|
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
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,
|
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.
|
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.
|
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
|
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||||
refresh only after actual successful product API requests, not cache reads.
|
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||||
Cache-only, empty and entirely failed cycles neither query nor clear activities.
|
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||||
In mixed cycles, Actions refreshes only workloads from fresh products and CRM SR
|
timestamp rather than the activity response time. Actions entries match each
|
||||||
refreshes once. Closing and reopening the modal preserves results and does not
|
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
|
reload products or trigger these lookups. Already-running product requests may
|
||||||
finish in the background and update their related activities.
|
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
|
aggregates all products: green means an action's `owner` matches the general
|
||||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
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
|
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||||
that load products from cache do not refresh activities.
|
that load products from cache do not refresh activities.
|
||||||
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
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",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -1134,8 +1135,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
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 = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1163,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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 productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
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) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
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 {
|
return {
|
||||||
start, contracts,
|
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",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -1134,8 +1135,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
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 = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1163,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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 productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
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) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
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 {
|
return {
|
||||||
start, contracts,
|
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/services/fusion/crm-service-requests.js",
|
||||||
"src/features/opportunities/crm-service-requests.js",
|
"src/features/opportunities/crm-service-requests.js",
|
||||||
"src/features/opportunities/product-activities.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/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",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
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 {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
@@ -20,6 +21,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
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) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
const opportunitiesTableState = createOpportunitiesTableState();
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
const preferencesState = createPreferencesState(contracts);
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||||
const productsContentState = createProductsContentState();
|
const productsContentState = createProductsContentState();
|
||||||
const aiSummaryState = createAiSummaryState();
|
const aiSummaryState = createAiSummaryState();
|
||||||
const consumptionPlansState = createConsumptionPlansState();
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
@@ -89,6 +90,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
@@ -218,6 +220,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||||
});
|
});
|
||||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||||
closePopover: (...args) => crmSrPopover.close(...args),
|
closePopover: (...args) => crmSrPopover.close(...args),
|
||||||
@@ -325,6 +329,29 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
environment
|
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({
|
const productActivities = createProductActivities({
|
||||||
productsContentState, opportunitiesQueryState,
|
productsContentState, opportunitiesQueryState,
|
||||||
resetCrm: () => opportunityCrmServiceRequests.reset(),
|
resetCrm: () => opportunityCrmServiceRequests.reset(),
|
||||||
@@ -347,6 +374,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
authSessionState,
|
authSessionState,
|
||||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
@@ -557,6 +586,9 @@ function createContentApplication(overrides = {}) {
|
|||||||
preferencesView,
|
preferencesView,
|
||||||
productsContentJobs,
|
productsContentJobs,
|
||||||
productActivities,
|
productActivities,
|
||||||
|
activityProgress,
|
||||||
|
refreshAll,
|
||||||
|
activityCache,
|
||||||
productsDetails,
|
productsDetails,
|
||||||
aiSummaryContent,
|
aiSummaryContent,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
|
|||||||
@@ -70,5 +70,20 @@ function createContentIcons({
|
|||||||
return svg;
|
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,
|
closeOwnerFilterPanel,
|
||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
createSettingsIcon,
|
createSettingsIcon,
|
||||||
|
createRefreshAllButton,
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles,
|
ensureExtensionStyles,
|
||||||
getCustomerOptions,
|
getCustomerOptions,
|
||||||
@@ -108,7 +109,6 @@ function createContentModal({
|
|||||||
overlay.setAttribute("role", "dialog");
|
overlay.setAttribute("role", "dialog");
|
||||||
overlay.setAttribute("aria-modal", "true");
|
overlay.setAttribute("aria-modal", "true");
|
||||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||||
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
|
||||||
|
|
||||||
const shell = document.createElement("section");
|
const shell = document.createElement("section");
|
||||||
shell.className = "opportunities-extension-shell";
|
shell.className = "opportunities-extension-shell";
|
||||||
@@ -120,10 +120,6 @@ function createContentModal({
|
|||||||
title.id = "opportunities-extension-title";
|
title.id = "opportunities-extension-title";
|
||||||
title.textContent = "Opportunities Extension";
|
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");
|
const authBadge = document.createElement("span");
|
||||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||||
@@ -143,9 +139,10 @@ function createContentModal({
|
|||||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
productsCacheTimestamp.setAttribute("role", "status");
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
productsCacheTimestamp.hidden = true;
|
productsCacheTimestamp.hidden = true;
|
||||||
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||||
|
|
||||||
const titleBlock = document.createElement("div");
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
@@ -174,7 +171,7 @@ function createContentModal({
|
|||||||
|
|
||||||
const headerActions = document.createElement("div");
|
const headerActions = document.createElement("div");
|
||||||
headerActions.className = "opportunities-extension-header-actions";
|
headerActions.className = "opportunities-extension-header-actions";
|
||||||
headerActions.append(themeButton, preferencesButton, closeButton);
|
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
|
||||||
|
|
||||||
header.append(titleBlock, headerActions);
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
current.promise = (async () => {
|
current.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
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);
|
const items = await pending.get(subPlanNum);
|
||||||
if (!section.isConnected || states.get(section) !== current) return;
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
current.status = "ready";
|
current.status = "ready";
|
||||||
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
})();
|
})();
|
||||||
return current.promise;
|
return current.promise;
|
||||||
}
|
}
|
||||||
return { createPlanDetailSection, loadPlanDetailForPanel };
|
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||||
|
currentRequests.set(optyNumber, { status: "loading" });
|
||||||
openAccountPlansModal(opportunity, [], "loading");
|
openAccountPlansModal(opportunity, [], "loading");
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestConsumerTokenServiceOnce()
|
requestConsumerTokenServiceOnce()
|
||||||
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
|
|||||||
authorizations
|
authorizations
|
||||||
}))
|
}))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
appendBackgroundRequestLog(response && response.debugRequests);
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
if (!response || !response.ok) {
|
||||||
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
|
|||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
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);
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
});
|
});
|
||||||
return button;
|
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 status = "idle";
|
||||||
let byOpportunity = new Map();
|
let byOpportunity = new Map();
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); }
|
||||||
function load(token, version) {
|
function load(token, version, cacheOptions = null) {
|
||||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||||
if (!pending) pending = run(token, version, generation);
|
if (!pending) pending = run(token, version, generation, cacheOptions);
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
async function run(token, version, currentGeneration) {
|
async function run(token, version, currentGeneration, cacheOptions) {
|
||||||
const isActive = () => currentGeneration === generation;
|
const isActive = () => currentGeneration === generation;
|
||||||
status = "loading";
|
status = "loading";
|
||||||
try {
|
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;
|
if (!items || !isActive()) return;
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -25,8 +35,9 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
}
|
}
|
||||||
byOpportunity = grouped;
|
byOpportunity = grouped;
|
||||||
status = "ready";
|
status = "ready";
|
||||||
|
if (!cached) reportProgress("complete", items.length, items.length);
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive()) status = "error";
|
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function createBadge(optyNumber) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
|||||||
if (!current || current.completed || current.requestId !== requestId
|
if (!current || current.completed || current.requestId !== requestId
|
||||||
|| productsContentState.currentProductsRequestId !== requestId) return;
|
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||||
current.completed = true;
|
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;
|
if (!products.length) return;
|
||||||
resetCrm();
|
resetCrm();
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -20,9 +20,10 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
return true;
|
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();
|
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render();
|
||||||
}).catch(() => {});
|
}).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 { 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 section + section { border-color: #5f5953; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
.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,
|
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, environment }) {
|
||||||
let session = newSession();
|
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) {
|
function reset(enabled = true) {
|
||||||
session.enabled = false;
|
session.enabled = false;
|
||||||
session.queue.length = 0;
|
session.queue.length = 0;
|
||||||
session = newSession();
|
session = newSession();
|
||||||
session.enabled = enabled;
|
session.enabled = enabled;
|
||||||
|
reportProgress("idle");
|
||||||
}
|
}
|
||||||
function idsFor(products) {
|
function idsFor(products) {
|
||||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
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.enabled || !allowsCrmActivities()) return;
|
||||||
|
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)) continue;
|
||||||
|
session.progress.delete(session.records.get(revnId));
|
||||||
const record = { status: "loading", actions: [], hasNext: false };
|
const record = { status: "loading", actions: [], hasNext: false };
|
||||||
session.records.set(revnId, record);
|
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);
|
pump(session);
|
||||||
}
|
}
|
||||||
function reopen() {
|
function reopen() {
|
||||||
@@ -33,23 +45,35 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function run(current, { revnId, record }) {
|
async function run(current, { revnId, record, cacheOptions }) {
|
||||||
try {
|
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();
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
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);
|
appendBackgroundRequestLog(result?.debugRequests);
|
||||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||||
record.actions = result.actions;
|
record.actions = result.actions;
|
||||||
record.hasNext = result.hasNext;
|
record.hasNext = result.hasNext;
|
||||||
record.status = "ready";
|
record.status = "ready";
|
||||||
|
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!current.enabled || current !== session) return;
|
if (!current.enabled || current !== session) return;
|
||||||
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 {
|
||||||
if (current.enabled && current === session) queueProductsTableRender();
|
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function createBadge(optyNumber) {
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
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 = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -24,15 +33,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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 productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
try {
|
try {
|
||||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
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));
|
handle.showItems(await pending.get(planNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.showError(error.message || "Unable to load rate card.");
|
handle.showError(error.message || "Unable to load rate card.");
|
||||||
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
button.addEventListener("click", () => showRatecard(plan, button));
|
button.addEventListener("click", () => showRatecard(plan, button));
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createRatecardButton, showRatecard };
|
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
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));
|
view.render(await pending.get(partyNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
});
|
});
|
||||||
return button;
|
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
|
"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 = [];
|
const items = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (isActive()) {
|
while (isActive()) {
|
||||||
@@ -41,6 +41,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
}
|
}
|
||||||
if (!isActive()) return null;
|
if (!isActive()) return null;
|
||||||
items.push(...data.items);
|
items.push(...data.items);
|
||||||
|
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||||
if (!data.hasMore) return items;
|
if (!data.hasMore) return items;
|
||||||
const received = data.count === undefined ? data.items.length : Number(data.count);
|
const received = data.count === undefined ? data.items.length : Number(data.count);
|
||||||
const current = data.offset === undefined ? offset : Number(data.offset);
|
const current = data.offset === undefined ? offset : Number(data.offset);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function createMessageTypes() {
|
|||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
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, /flex: 0 0 24px/);
|
||||||
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
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.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, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
|
||||||
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ function setup() {
|
|||||||
const calls = [];
|
const calls = [];
|
||||||
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||||
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
|
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
|
||||||
app.opportunityCrmServiceRequests.load = async token => calls.push(`SR ${token}`);
|
app.opportunityCrmServiceRequests.load = async (token, version, options) => { if (!options?.cacheOnly) calls.push(`SR ${token}`); };
|
||||||
app.opportunityWorkloadActions.loadProducts = products => calls.push(products.items[0]?.RevnId || "empty products");
|
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
||||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
function start(token = "token") {
|
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);
|
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 h = setup();
|
||||||
const cached = h.start();
|
const cached = h.start();
|
||||||
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
h.progress(cached, { state: "complete" });
|
h.progress(cached, { state: "complete" });
|
||||||
await flush();
|
await flush();
|
||||||
assert.deepEqual(h.calls, []);
|
assert.deepEqual(h.calls, ["reset SR"]);
|
||||||
const mixed = h.start();
|
const mixed = h.start();
|
||||||
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||||
h.progress(mixed, { state: "complete" });
|
h.progress(mixed, { state: "complete" });
|
||||||
await flush();
|
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 () => {
|
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