diff --git a/README.md b/README.md index ff5b032..c8180cf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ If identity or roles cannot be validated, only these two features stay disabled; other opportunity features continue. Reload the page to refresh identity/roles. The unused identity token is discarded and redacted from debug responses. -Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of +After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of `CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`, using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest` query endpoint, independently of the opportunity query version. Calls appear in @@ -37,22 +37,25 @@ 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 and do not wait for product downloads. SR results remain -in memory only. Reference changes share the in-flight lookup and reuse its result -(including failures) without another request. Closing resets this state; reopening -starts a fresh lookup, and responses from previous openings are ignored. +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 +reload products or trigger these lookups. Already-running product requests may +finish in the background and update their related activities. ## Workload Actions -Workload Actions are queried in the background after product results arrive, -including cached products. Each distinct `RevnId` uses `requestWlActions` with +Workload Actions are queried in the background after the product cycle completes, +using only fresh API results. Each distinct `RevnId` uses `requestWlActions` with the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR 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. -Queries are shared per modal opening (up to four concurrently), reused -across Reference changes, and discarded on close. Reopening queries again. +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. The debug panel groups these requests under Actions, with credentials masked. diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 3b9b406..1019870 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -466,10 +466,7 @@ function createContentModal({ renderStageFilterButtons, renderStatusFilterButtons, requestOpportunitiesForPeriod, - loadCrmServiceRequests, - resetCrmServiceRequests, - resetWorkloadActions, - reopenWorkloadActions, + closeCrmSrPopover, requestTokenRelayOnce, ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, @@ -503,10 +500,7 @@ function createContentModal({ ensureEscapeKeyHandler(); ensureCurrentUser().then(() => { if (existingModal.hidden) return; - reopenWorkloadActions(); - return requestTokenRelayOnce().then(token => { - if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); - }); + return requestTokenRelayOnce(); }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; @@ -884,8 +878,7 @@ function createContentModal({ function closeOpportunitiesModal() { resetAuthCompletion(); - resetWorkloadActions(false); - resetCrmServiceRequests(); + closeCrmSrPopover(); const modal = document.getElementById(MODAL_ID); if (modal) { @@ -1589,7 +1582,6 @@ function createOpportunitiesController({ productsContentState, renderProductsProgress, requestOpportunitiesPage, - loadCrmServiceRequests, resetCrmServiceRequests, resetWorkloadActions, requestOpptyProductsInBackground, @@ -1660,7 +1652,6 @@ function createOpportunitiesController({ } try { - await loadCrmServiceRequests(token, requestVersion); if (!isCurrentPeriodRequest(requestVersion)) return; const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); @@ -2027,6 +2018,38 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre return { reset, load, createBadge }; } +// Source: src/features/opportunities/product-activities.js +function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm, + loadCrm, loadActions, render }) { + let cycle = null; + function begin(requestId, token) { + cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + } + function complete(requestId) { + const current = cycle; + 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); + if (!products.length) return; + resetCrm(); + const seen = new Set(); + for (const result of products) { + const items = result.items.filter(item => { + const id = String(item?.RevnId ?? ""); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + loadActions({ ...result, items }, true); + } + Promise.resolve(loadCrm(current.token, current.version)).finally(() => { + if (cycle === current && productsContentState.currentProductsRequestId === requestId) render(); + }).catch(() => {}); + } + return { begin, complete }; +} + // Source: src/features/opportunities/crm-sr-popover.js function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) { const { document, window, setTimeout, clearTimeout } = environment; @@ -2171,10 +2194,10 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie function idsFor(products) { return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } - function loadProducts(products) { + function loadProducts(products, refresh = false) { if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { - if (session.records.has(revnId)) continue; + if (!refresh && session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; session.records.set(revnId, record); session.queue.push({ revnId, record }); @@ -2196,7 +2219,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session || !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 }); if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); @@ -3626,7 +3649,8 @@ function createProductsContentJobs({ preferencesState, productsContentState, renderOpportunitiesTable, - loadWorkloadActions, + beginProductActivities, + completeProductActivities, sendRuntimeMessage, environment }) { @@ -3671,6 +3695,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; + beginProductActivities(requestId, token); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { @@ -3738,7 +3763,6 @@ function createProductsContentJobs({ cachedAt: Number(message.cachedAt) || 0, error: message.error || "" }); - loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber))); } if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { @@ -3753,6 +3777,7 @@ function createProductsContentJobs({ cacheHits: Number(message.cacheHits) || 0 }; renderProductsProgress(); + if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId); queueProductsTableRender(); return false; }); @@ -6897,6 +6922,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + closeCrmSrPopover: () => crmSrPopover.close(), ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, @@ -6953,10 +6979,6 @@ function createContentApplication(overrides = {}) { requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), - resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), - reopenWorkloadActions: () => opportunityWorkloadActions.reopen(), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), - resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), @@ -7032,7 +7054,6 @@ function createContentApplication(overrides = {}) { productsContentState, renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), @@ -7172,8 +7193,16 @@ function createContentApplication(overrides = {}) { requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), environment }); + const productActivities = createProductActivities({ + productsContentState, opportunitiesQueryState, + resetCrm: () => opportunityCrmServiceRequests.reset(), + loadCrm: (...args) => opportunityCrmServiceRequests.load(...args), + loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + render: () => productsContentJobs.queueProductsTableRender() + }); const productsContentJobs = createProductsContentJobs({ - loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + beginProductActivities: (...args) => productActivities.begin(...args), + completeProductActivities: (...args) => productActivities.complete(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), messageTypes, PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, @@ -7395,6 +7424,7 @@ function createContentApplication(overrides = {}) { preferencesStorage, preferencesView, productsContentJobs, + productActivities, productsDetails, aiSummaryContent, consumptionPlansAction, diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 3b9b406..1019870 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -466,10 +466,7 @@ function createContentModal({ renderStageFilterButtons, renderStatusFilterButtons, requestOpportunitiesForPeriod, - loadCrmServiceRequests, - resetCrmServiceRequests, - resetWorkloadActions, - reopenWorkloadActions, + closeCrmSrPopover, requestTokenRelayOnce, ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, @@ -503,10 +500,7 @@ function createContentModal({ ensureEscapeKeyHandler(); ensureCurrentUser().then(() => { if (existingModal.hidden) return; - reopenWorkloadActions(); - return requestTokenRelayOnce().then(token => { - if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); - }); + return requestTokenRelayOnce(); }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; @@ -884,8 +878,7 @@ function createContentModal({ function closeOpportunitiesModal() { resetAuthCompletion(); - resetWorkloadActions(false); - resetCrmServiceRequests(); + closeCrmSrPopover(); const modal = document.getElementById(MODAL_ID); if (modal) { @@ -1589,7 +1582,6 @@ function createOpportunitiesController({ productsContentState, renderProductsProgress, requestOpportunitiesPage, - loadCrmServiceRequests, resetCrmServiceRequests, resetWorkloadActions, requestOpptyProductsInBackground, @@ -1660,7 +1652,6 @@ function createOpportunitiesController({ } try { - await loadCrmServiceRequests(token, requestVersion); if (!isCurrentPeriodRequest(requestVersion)) return; const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); @@ -2027,6 +2018,38 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre return { reset, load, createBadge }; } +// Source: src/features/opportunities/product-activities.js +function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm, + loadCrm, loadActions, render }) { + let cycle = null; + function begin(requestId, token) { + cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + } + function complete(requestId) { + const current = cycle; + 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); + if (!products.length) return; + resetCrm(); + const seen = new Set(); + for (const result of products) { + const items = result.items.filter(item => { + const id = String(item?.RevnId ?? ""); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + loadActions({ ...result, items }, true); + } + Promise.resolve(loadCrm(current.token, current.version)).finally(() => { + if (cycle === current && productsContentState.currentProductsRequestId === requestId) render(); + }).catch(() => {}); + } + return { begin, complete }; +} + // Source: src/features/opportunities/crm-sr-popover.js function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) { const { document, window, setTimeout, clearTimeout } = environment; @@ -2171,10 +2194,10 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie function idsFor(products) { return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } - function loadProducts(products) { + function loadProducts(products, refresh = false) { if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { - if (session.records.has(revnId)) continue; + if (!refresh && session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; session.records.set(revnId, record); session.queue.push({ revnId, record }); @@ -2196,7 +2219,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session || !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 }); if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); @@ -3626,7 +3649,8 @@ function createProductsContentJobs({ preferencesState, productsContentState, renderOpportunitiesTable, - loadWorkloadActions, + beginProductActivities, + completeProductActivities, sendRuntimeMessage, environment }) { @@ -3671,6 +3695,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; + beginProductActivities(requestId, token); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { @@ -3738,7 +3763,6 @@ function createProductsContentJobs({ cachedAt: Number(message.cachedAt) || 0, error: message.error || "" }); - loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber))); } if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { @@ -3753,6 +3777,7 @@ function createProductsContentJobs({ cacheHits: Number(message.cacheHits) || 0 }; renderProductsProgress(); + if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId); queueProductsTableRender(); return false; }); @@ -6897,6 +6922,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + closeCrmSrPopover: () => crmSrPopover.close(), ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, @@ -6953,10 +6979,6 @@ function createContentApplication(overrides = {}) { requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), - resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), - reopenWorkloadActions: () => opportunityWorkloadActions.reopen(), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), - resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), @@ -7032,7 +7054,6 @@ function createContentApplication(overrides = {}) { productsContentState, renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), @@ -7172,8 +7193,16 @@ function createContentApplication(overrides = {}) { requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), environment }); + const productActivities = createProductActivities({ + productsContentState, opportunitiesQueryState, + resetCrm: () => opportunityCrmServiceRequests.reset(), + loadCrm: (...args) => opportunityCrmServiceRequests.load(...args), + loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + render: () => productsContentJobs.queueProductsTableRender() + }); const productsContentJobs = createProductsContentJobs({ - loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + beginProductActivities: (...args) => productActivities.begin(...args), + completeProductActivities: (...args) => productActivities.complete(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), messageTypes, PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, @@ -7395,6 +7424,7 @@ function createContentApplication(overrides = {}) { preferencesStorage, preferencesView, productsContentJobs, + productActivities, productsDetails, aiSummaryContent, consumptionPlansAction, diff --git a/scripts/sources.cjs b/scripts/sources.cjs index 417fc3e..86630e4 100644 --- a/scripts/sources.cjs +++ b/scripts/sources.cjs @@ -32,6 +32,7 @@ module.exports = { "src/services/fusion/opportunities.js", "src/services/fusion/crm-service-requests.js", "src/features/opportunities/crm-service-requests.js", + "src/features/opportunities/product-activities.js", "src/features/opportunities/crm-sr-popover.js", "src/features/opportunities/workload-actions.js", "src/features/opportunities/fiscal-periods.js", diff --git a/src/content/application.js b/src/content/application.js index d24f891..72f3a8d 100644 --- a/src/content/application.js +++ b/src/content/application.js @@ -54,6 +54,7 @@ function createContentApplication(overrides = {}) { environment }); const contentModal = createContentModal({ + closeCrmSrPopover: () => crmSrPopover.close(), ensureCurrentUser: () => currentUser.ensure(), ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID, AUTH_STATUS: contracts.AUTH_STATUS, @@ -110,10 +111,6 @@ function createContentApplication(overrides = {}) { requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args), resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args), - resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args), - reopenWorkloadActions: () => opportunityWorkloadActions.reopen(), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), - resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args), saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args), toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args), @@ -189,7 +186,6 @@ function createContentApplication(overrides = {}) { productsContentState, renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args), requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args), - loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args), resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(), requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args), requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args), @@ -329,8 +325,16 @@ function createContentApplication(overrides = {}) { requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args), environment }); + const productActivities = createProductActivities({ + productsContentState, opportunitiesQueryState, + resetCrm: () => opportunityCrmServiceRequests.reset(), + loadCrm: (...args) => opportunityCrmServiceRequests.load(...args), + loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + render: () => productsContentJobs.queueProductsTableRender() + }); const productsContentJobs = createProductsContentJobs({ - loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args), + beginProductActivities: (...args) => productActivities.begin(...args), + completeProductActivities: (...args) => productActivities.complete(...args), appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args), messageTypes, PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, @@ -552,6 +556,7 @@ function createContentApplication(overrides = {}) { preferencesStorage, preferencesView, productsContentJobs, + productActivities, productsDetails, aiSummaryContent, consumptionPlansAction, diff --git a/src/content/modal.js b/src/content/modal.js index 2836013..73e53a1 100644 --- a/src/content/modal.js +++ b/src/content/modal.js @@ -51,10 +51,7 @@ function createContentModal({ renderStageFilterButtons, renderStatusFilterButtons, requestOpportunitiesForPeriod, - loadCrmServiceRequests, - resetCrmServiceRequests, - resetWorkloadActions, - reopenWorkloadActions, + closeCrmSrPopover, requestTokenRelayOnce, ensureCurrentUser, resetCustomerAndOwnerFiltersForPeriod, @@ -88,10 +85,7 @@ function createContentModal({ ensureEscapeKeyHandler(); ensureCurrentUser().then(() => { if (existingModal.hidden) return; - reopenWorkloadActions(); - return requestTokenRelayOnce().then(token => { - if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion); - }); + return requestTokenRelayOnce(); }) .then(() => renderOpportunitiesTable()).catch(() => {}); return; @@ -469,8 +463,7 @@ function createContentModal({ function closeOpportunitiesModal() { resetAuthCompletion(); - resetWorkloadActions(false); - resetCrmServiceRequests(); + closeCrmSrPopover(); const modal = document.getElementById(MODAL_ID); if (modal) { diff --git a/src/features/opportunities/controller.js b/src/features/opportunities/controller.js index 2ad1440..a9fd221 100644 --- a/src/features/opportunities/controller.js +++ b/src/features/opportunities/controller.js @@ -13,7 +13,6 @@ function createOpportunitiesController({ productsContentState, renderProductsProgress, requestOpportunitiesPage, - loadCrmServiceRequests, resetCrmServiceRequests, resetWorkloadActions, requestOpptyProductsInBackground, @@ -84,7 +83,6 @@ function createOpportunitiesController({ } try { - await loadCrmServiceRequests(token, requestVersion); if (!isCurrentPeriodRequest(requestVersion)) return; const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1); diff --git a/src/features/opportunities/product-activities.js b/src/features/opportunities/product-activities.js new file mode 100644 index 0000000..beeb5b8 --- /dev/null +++ b/src/features/opportunities/product-activities.js @@ -0,0 +1,30 @@ +function createProductActivities({ productsContentState, opportunitiesQueryState, resetCrm, + loadCrm, loadActions, render }) { + let cycle = null; + function begin(requestId, token) { + cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + } + function complete(requestId) { + const current = cycle; + 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); + if (!products.length) return; + resetCrm(); + const seen = new Set(); + for (const result of products) { + const items = result.items.filter(item => { + const id = String(item?.RevnId ?? ""); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + loadActions({ ...result, items }, true); + } + Promise.resolve(loadCrm(current.token, current.version)).finally(() => { + if (cycle === current && productsContentState.currentProductsRequestId === requestId) render(); + }).catch(() => {}); + } + return { begin, complete }; +} diff --git a/src/features/opportunities/workload-actions.js b/src/features/opportunities/workload-actions.js index b2e6e2a..7eafe0c 100644 --- a/src/features/opportunities/workload-actions.js +++ b/src/features/opportunities/workload-actions.js @@ -11,10 +11,10 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie function idsFor(products) { return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))]; } - function loadProducts(products) { + function loadProducts(products, refresh = false) { if (!session.enabled || !allowsCrmActivities()) return; for (const revnId of idsFor(products)) { - if (session.records.has(revnId)) continue; + if (!refresh && session.records.has(revnId)) continue; const record = { status: "loading", actions: [], hasNext: false }; session.records.set(revnId, record); session.queue.push({ revnId, record }); @@ -36,7 +36,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie async function run(current, { revnId, record }) { try { const authorizations = await requestConsumerTokenServiceOnce(); - if (!current.enabled || current !== session || !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 }); if (!current.enabled || current !== session || !allowsCrmActivities()) return; appendBackgroundRequestLog(result?.debugRequests); diff --git a/src/features/products/content-jobs.js b/src/features/products/content-jobs.js index 31a4dea..d8d9ae3 100644 --- a/src/features/products/content-jobs.js +++ b/src/features/products/content-jobs.js @@ -7,7 +7,8 @@ function createProductsContentJobs({ preferencesState, productsContentState, renderOpportunitiesTable, - loadWorkloadActions, + beginProductActivities, + completeProductActivities, sendRuntimeMessage, environment }) { @@ -52,6 +53,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; + beginProductActivities(requestId, token); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { @@ -119,7 +121,6 @@ function createProductsContentJobs({ cachedAt: Number(message.cachedAt) || 0, error: message.error || "" }); - loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber))); } if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) { @@ -134,6 +135,7 @@ function createProductsContentJobs({ cacheHits: Number(message.cacheHits) || 0 }; renderProductsProgress(); + if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId); queueProductsTableRender(); return false; }); diff --git a/tests/crmServiceRequests.test.cjs b/tests/crmServiceRequests.test.cjs index 6f5b4f1..5a06fe3 100644 --- a/tests/crmServiceRequests.test.cjs +++ b/tests/crmServiceRequests.test.cjs @@ -37,7 +37,7 @@ test("CRM fetch is called without the environment object as its receiver", async assert.equal(items.length, 2); }); -test("CRM query preserves payload, paginates before opportunities, uses tokenrelay Bearer and logs each page", async () => { +test("CRM query preserves payload and pagination after opportunities, uses tokenrelay Bearer and logs each page", async () => { const calls = []; const app = setup(async (url, options) => { const payload = JSON.parse(options.body); @@ -49,11 +49,12 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel app.productsContentJobs.renderProductsProgress = () => {}; app.productsContentJobs.requestOpptyProductsInBackground = () => {}; app.fusionOpportunities.requestOpportunitiesPage = async () => { - assert.equal(calls.length, 2, "all CRM pages must finish first"); + assert.equal(calls.length, 0, "CRM must not run before product loading"); return [{ OptyNumber: "TEST-1" }]; }; await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1); assert.equal(states.at(-1).status, "ready"); + await app.opportunityCrmServiceRequests.load("synthetic-token", 1); assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]); const { payload, options, url } = calls[0]; assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries"); @@ -116,6 +117,7 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; }; await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1); assert.equal(loaded, true); + await app.opportunityCrmServiceRequests.load("token", 1); assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?"); assert.equal(app.debugState.requestLog.at(-1).status, "failed"); } diff --git a/tests/currentUser.test.cjs b/tests/currentUser.test.cjs index 52e3410..d31ccd5 100644 --- a/tests/currentUser.test.cjs +++ b/tests/currentUser.test.cjs @@ -73,10 +73,12 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL assert.equal(same, app.currentUser.ensure()); finish({ ok: true, user: { roles } }); await first; - assert.deepEqual(calls, allowed ? [type, "tokenrelay", "crm", "opportunities"] : [type, "tokenrelay", "opportunities"]); + assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]); + app.productsContentState.currentProductsRequestId = "products-cycle"; + app.productActivities.begin("products-cycle", "synthetic"); const products = { items: [{ RevnId: "123" }] }; app.productsContentState.opptyProductsByNumber.set("OP", products); - app.opportunityWorkloadActions.loadProducts(products); + app.productActivities.complete("products-cycle"); await new Promise(resolve => setImmediate(resolve)); assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed); if (!allowed) { diff --git a/tests/productActivities.test.cjs b/tests/productActivities.test.cjs new file mode 100644 index 0000000..266e43c --- /dev/null +++ b/tests/productActivities.test.cjs @@ -0,0 +1,109 @@ +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() { + let listener; + const app = loadApplication("content", { + document: { getElementById: () => null }, window: { requestAnimationFrame() {} }, + chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } } + }); + 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.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true }); + app.productsContentJobs.ensureProductsBackgroundMessageHandler(); + function start(token = "token") { + app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], token); + return app.productsContentState.currentProductsRequestId; + } + function progress(requestId, data) { + listener({ type: "opportunitiesExtension.opptyProductsProgress", requestId, ...data }); + } + return { app, calls, start, progress }; +} + +test("follow-up calls wait for completion, deduplicate terminal messages and refresh on every new cycle", async () => { + const h = setup(); + const first = h.start(); + h.progress(first, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false }); + assert.deepEqual(h.calls, []); + h.progress(first, { state: "complete" }); + h.progress(first, { state: "complete" }); + await flush(); + assert.deepEqual(h.calls, ["reset SR", "123", "SR token"]); + const second = h.start("new-token"); + h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] }); + h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] }); + assert.equal(h.calls.filter(value => value === "123").length, 1); + h.progress(second, { state: "complete" }); + await flush(); + assert.equal(h.calls.filter(value => value === "123").length, 2); + assert.ok(h.calls.includes("SR new-token")); + assert.equal(h.calls.includes("old"), false); +}); + +test("partial failures use successful products only; closed, failed and empty cycles have no follow-up", async () => { + const h = setup(); + const first = h.start(); + h.progress(first, { optyNumber: "A", items: [{ RevnId: "123" }] }); + h.progress(first, { optyNumber: "B", items: [{ RevnId: "failed" }], error: "Synthetic failure" }); + h.progress(first, { state: "complete-with-errors" }); + await flush(); + assert.equal(h.calls.includes("failed"), false); + assert.equal(h.calls.filter(value => value === "SR token").length, 1); + const second = h.start(); + h.progress(second, { optyNumber: "A", error: "Synthetic failure" }); + h.progress(second, { state: "complete-with-errors" }); + const third = h.start(); + h.progress(third, { state: "error" }); + const fourth = h.start(); + h.app.productsContentState.currentProductsRequestId = ""; + h.progress(fourth, { state: "complete", optyNumber: "A", items: [{ RevnId: "closed" }] }); + h.app.productsContentJobs.requestOpptyProductsInBackground([], "empty"); + await flush(); + assert.equal(h.calls.filter(value => value.startsWith("SR ")).length, 1); + assert.equal(h.calls.includes("closed"), false); +}); + +test("cached products never trigger or reset activities; mixed cycles use 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, []); + 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"]); +}); + +test("reopening an existing modal does not reload products or reset/query activities", async () => { + const modal = { hidden: true, querySelector: () => ({ focus() {} }) }; + const app = loadApplication("content", { + window: {}, document: { + getElementById: id => id === "opportunities-extension-modal" ? modal : null, + documentElement: { classList: { add() {} } }, addEventListener() {} + } + }); + app.contentStyles.ensureExtensionStyles = () => {}; + app.currentUser.ensure = async () => true; + app.authContentSession.requestTokenRelayOnce = async () => "synthetic"; + app.opportunitiesTable.renderOpportunitiesTable = () => {}; + const calls = []; + app.productsContentJobs.requestOpptyProductsInBackground = () => calls.push("products"); + app.opportunityCrmServiceRequests.load = () => calls.push("SR"); + app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR"); + app.opportunityWorkloadActions.loadProducts = () => calls.push("Actions"); + app.opportunityWorkloadActions.reset = () => calls.push("reset Actions"); + app.contentModal.openOpportunitiesModal(); + await flush(); + assert.equal(modal.hidden, false); + assert.deepEqual(calls, []); +}); diff --git a/tests/workloadActions.test.cjs b/tests/workloadActions.test.cjs index eaf3cf6..f7602b0 100644 --- a/tests/workloadActions.test.cjs +++ b/tests/workloadActions.test.cjs @@ -158,7 +158,7 @@ test("actions queue caps concurrency and discards results/queued jobs from close assert.equal(app.opportunityWorkloadActions.createBadge("A"), null); }); -test("product progress triggers Actions for cached products and ignores stale product jobs", async () => { +test("product progress triggers Actions only for fresh products and ignores stale product jobs", async () => { let listener; const calls = []; const app = loadApplication("content", { @@ -173,14 +173,19 @@ test("product progress triggers Actions for cached products and ignores stale pr return { ok: true, actions: [], hasNext: false }; }; app.productsContentState.currentProductsRequestId = "current"; + app.productActivities.begin("current", "synthetic"); + app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => []; app.productsContentJobs.ensureProductsBackgroundMessageHandler(); const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress", - requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true }; + requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false }; listener({ ...message, requestId: "old" }); await flush(); assert.equal(calls.length, 0); listener(message); await flush(); + assert.equal(calls.length, 0); + listener({ ...message, optyNumber: undefined, state: "complete" }); + await flush(); assert.equal(calls.length, 1); assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions"); assert.equal(calls[0].revnId, "123");