Updated the calendar to improve opportunity display and navigation
This commit is contained in:
32
dist/chromium/background.js
vendored
32
dist/chromium/background.js
vendored
@@ -1553,7 +1553,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let session = sessions.get(key);
|
||||
if (!session) {
|
||||
const details = {
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests",
|
||||
active: false
|
||||
};
|
||||
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||
@@ -1570,8 +1570,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let tab;
|
||||
try {
|
||||
tab = await session.tab;
|
||||
const deadline = Date.now() + 60000;
|
||||
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||
const deadline = Date.now() + 70000;
|
||||
for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
|
||||
const pendingCookies = initialize && attempt === 0
|
||||
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||
@@ -1590,7 +1590,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
if (error.httpStatus !== 401) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||
throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
|
||||
} catch (error) {
|
||||
error.debugRequests = logs;
|
||||
throw error;
|
||||
@@ -1879,12 +1879,29 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
}
|
||||
|
||||
// Source: src/services/spa/current-user.js
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
|
||||
const pending = new Map();
|
||||
const identities = new Map();
|
||||
function requestCurrentUser(sender) {
|
||||
const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
// Windows share cookies; Firefox containers and private sessions do not.
|
||||
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
|
||||
if (!pending.has(key)) {
|
||||
pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key)));
|
||||
pending.set(key, (async () => {
|
||||
const storageKey = `opportunities-extension-current-user:${key}`;
|
||||
try {
|
||||
const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
|
||||
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
|
||||
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
|
||||
identities.set(key, stored);
|
||||
return { ok: true, user: stored, debugRequests: [] };
|
||||
}
|
||||
} catch { /* Memory cache still works if session storage is unavailable. */ }
|
||||
const result = await loadCurrentUser(sender);
|
||||
identities.set(key, result.user);
|
||||
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
|
||||
return result;
|
||||
})().finally(() => pending.delete(key)));
|
||||
}
|
||||
return pending.get(key);
|
||||
}
|
||||
@@ -3370,6 +3387,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
getHttp: () => httpBackgroundJson
|
||||
});
|
||||
const spaCurrentUser = createSpaCurrentUser({
|
||||
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
|
||||
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
|
||||
|
||||
648
dist/chromium/content.js
vendored
648
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
32
dist/firefox/background.js
vendored
32
dist/firefox/background.js
vendored
@@ -1553,7 +1553,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let session = sessions.get(key);
|
||||
if (!session) {
|
||||
const details = {
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests",
|
||||
active: false
|
||||
};
|
||||
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||
@@ -1570,8 +1570,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let tab;
|
||||
try {
|
||||
tab = await session.tab;
|
||||
const deadline = Date.now() + 60000;
|
||||
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||
const deadline = Date.now() + 70000;
|
||||
for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
|
||||
const pendingCookies = initialize && attempt === 0
|
||||
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||
@@ -1590,7 +1590,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
if (error.httpStatus !== 401) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||
throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
|
||||
} catch (error) {
|
||||
error.debugRequests = logs;
|
||||
throw error;
|
||||
@@ -1879,12 +1879,29 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
}
|
||||
|
||||
// Source: src/services/spa/current-user.js
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
|
||||
const pending = new Map();
|
||||
const identities = new Map();
|
||||
function requestCurrentUser(sender) {
|
||||
const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
// Windows share cookies; Firefox containers and private sessions do not.
|
||||
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
|
||||
if (!pending.has(key)) {
|
||||
pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key)));
|
||||
pending.set(key, (async () => {
|
||||
const storageKey = `opportunities-extension-current-user:${key}`;
|
||||
try {
|
||||
const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
|
||||
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
|
||||
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
|
||||
identities.set(key, stored);
|
||||
return { ok: true, user: stored, debugRequests: [] };
|
||||
}
|
||||
} catch { /* Memory cache still works if session storage is unavailable. */ }
|
||||
const result = await loadCurrentUser(sender);
|
||||
identities.set(key, result.user);
|
||||
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
|
||||
return result;
|
||||
})().finally(() => pending.delete(key)));
|
||||
}
|
||||
return pending.get(key);
|
||||
}
|
||||
@@ -3370,6 +3387,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
getHttp: () => httpBackgroundJson
|
||||
});
|
||||
const spaCurrentUser = createSpaCurrentUser({
|
||||
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
|
||||
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
|
||||
|
||||
648
dist/firefox/content.js
vendored
648
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -13,14 +13,12 @@ module.exports = {
|
||||
"src/shared/debug-body.js",
|
||||
"src/content/contracts.js",
|
||||
"src/auth/session-state.js",
|
||||
"src/auth/sales-common-state.js",
|
||||
"src/features/opportunities/query-state.js",
|
||||
"src/features/opportunities/table-state.js",
|
||||
"src/features/opportunities/filter-state.js",
|
||||
"src/features/preferences/state.js",
|
||||
"src/features/preferences/request-cache.js",
|
||||
"src/features/products/content-state.js",
|
||||
"src/features/ai-summary/state.js",
|
||||
"src/features/consumption/plans-state.js",
|
||||
"src/features/consumption/trend-state.js",
|
||||
"src/features/opportunities/dashboard-state.js",
|
||||
@@ -35,7 +33,6 @@ module.exports = {
|
||||
"src/content/styles.js",
|
||||
"src/auth/content-session.js",
|
||||
"src/auth/current-user.js",
|
||||
"src/auth/sales-common.js",
|
||||
"src/auth/token-relay.js",
|
||||
"src/auth/xsrf-content.js",
|
||||
"src/platform/content-messages.js",
|
||||
@@ -72,7 +69,6 @@ module.exports = {
|
||||
"src/features/products/ramp-projection.js",
|
||||
"src/features/products/forecast-update.js",
|
||||
"src/features/products/ramp-projection-view.js",
|
||||
"src/features/ai-summary/content.js",
|
||||
"src/features/consumption/plans-action.js",
|
||||
"src/features/install-base/action.js",
|
||||
"src/features/install-base/view.js",
|
||||
|
||||
@@ -19,6 +19,7 @@ function createAuthContentSession({
|
||||
return token;
|
||||
})
|
||||
.catch((error) => {
|
||||
authSessionState.tokenRelayRequest = null;
|
||||
authSessionState.accessToken = "";
|
||||
authSessionState.principal = "";
|
||||
authSessionState.authStatus = AUTH_STATUS.unauthenticated;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/** @returns {{salesCommonToken: null | {access_token: string, expiresAt: number}, salesCommonTokenRequest: null | Promise<string>}} */
|
||||
function createAuthSalesCommonState() {
|
||||
return {
|
||||
salesCommonToken: null,
|
||||
salesCommonTokenRequest: null
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
function createAuthSalesCommon({
|
||||
TOKEN_RELAY_URL,
|
||||
authSalesCommonState,
|
||||
requestTokenRelay
|
||||
}) {
|
||||
function requestTokenRelaySalesCommon() {
|
||||
if (authSalesCommonState.salesCommonToken && Date.now() < authSalesCommonState.salesCommonToken.expiresAt) {
|
||||
return Promise.resolve(authSalesCommonState.salesCommonToken.access_token);
|
||||
}
|
||||
if (authSalesCommonState.salesCommonTokenRequest) {
|
||||
return authSalesCommonState.salesCommonTokenRequest;
|
||||
}
|
||||
authSalesCommonState.salesCommonTokenRequest = requestTokenRelay({
|
||||
name: "requestTokenRelaySalesCommon",
|
||||
url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`,
|
||||
includeExpiry: true
|
||||
}).then((token) => {
|
||||
authSalesCommonState.salesCommonToken = token;
|
||||
return token.access_token;
|
||||
}).finally(() => {
|
||||
authSalesCommonState.salesCommonTokenRequest = null;
|
||||
});
|
||||
return authSalesCommonState.salesCommonTokenRequest;
|
||||
}
|
||||
|
||||
function invalidateSalesCommonToken(rejectedToken) {
|
||||
if (authSalesCommonState.salesCommonToken && authSalesCommonState.salesCommonToken.access_token === rejectedToken) {
|
||||
authSalesCommonState.salesCommonToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestTokenRelaySalesCommon, invalidateSalesCommonToken };
|
||||
}
|
||||
@@ -34,17 +34,27 @@ function createAuthTokenRelay({
|
||||
}
|
||||
|
||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||
const controller = environment.AbortController ? new environment.AbortController() : null;
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
if (!environment.setTimeout) return;
|
||||
timeoutId = environment.setTimeout(() => {
|
||||
reject(new Error("Token relay timed out after 30 seconds. Please try again."));
|
||||
controller?.abort();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await Promise.race([fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
headers: {
|
||||
"x-xsrf-token": xsrfTokenSource.token
|
||||
}
|
||||
});
|
||||
}), timeout]);
|
||||
|
||||
const responseBody = await readResponseBodyForDebug(response);
|
||||
const responseBody = await Promise.race([readResponseBodyForDebug(response), timeout]);
|
||||
|
||||
if (!response.ok) {
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
@@ -63,8 +73,9 @@ function createAuthTokenRelay({
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
responseData = await Promise.race([response.json(), timeout]);
|
||||
} catch (error) {
|
||||
if (controller?.signal.aborted) throw error;
|
||||
logRequestSuccess(requestEntry, response, responseBody);
|
||||
|
||||
if (!refreshAttempted) {
|
||||
@@ -109,6 +120,8 @@ function createAuthTokenRelay({
|
||||
} catch (error) {
|
||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId !== undefined) environment.clearTimeout?.(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
getHttp: () => httpBackgroundJson
|
||||
});
|
||||
const spaCurrentUser = createSpaCurrentUser({
|
||||
sessionStorage: (environment.browser || environment.chrome)?.storage?.session,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
fetchInTab: (tab, logs) => spaPageClient.requestIdentity(tab, logs),
|
||||
openSessionTab: sender => authConsumerSession.openConsumerTokenSessionTab(sender),
|
||||
|
||||
@@ -5,7 +5,7 @@ function createContentApplication(overrides = {}) {
|
||||
navigator: globalThis.navigator,
|
||||
crypto: globalThis.crypto,
|
||||
get localStorage() { return globalThis.localStorage; },
|
||||
fetch: globalThis.fetch,
|
||||
fetch: globalThis.fetch && ((...args) => globalThis.fetch.call(undefined, ...args)),
|
||||
browser: globalThis.browser,
|
||||
chrome: globalThis.chrome,
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
@@ -13,8 +13,9 @@ function createContentApplication(overrides = {}) {
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
performance: globalThis.performance,
|
||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
setTimeout: globalThis.setTimeout && ((...args) => globalThis.setTimeout.call(undefined, ...args)),
|
||||
clearTimeout: globalThis.clearTimeout && ((...args) => globalThis.clearTimeout.call(undefined, ...args)),
|
||||
AbortController: globalThis.AbortController,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
...overrides
|
||||
};
|
||||
@@ -28,7 +29,6 @@ function createContentApplication(overrides = {}) {
|
||||
const contracts = createContentContracts();
|
||||
const searchableSelect = createSearchableSelect({ environment });
|
||||
const authSessionState = createAuthSessionState(contracts);
|
||||
const authSalesCommonState = createAuthSalesCommonState();
|
||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||
const tableSorting = createTableSorting({ environment });
|
||||
const orgChartIcons = createOrgChartIcons({ environment });
|
||||
@@ -57,11 +57,10 @@ function createContentApplication(overrides = {}) {
|
||||
const preferencesState = createPreferencesState(contracts);
|
||||
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||
const productsContentState = createProductsContentState();
|
||||
const aiSummaryState = createAiSummaryState();
|
||||
const consumptionPlansState = createConsumptionPlansState();
|
||||
const consumptionTrendState = createConsumptionTrendState();
|
||||
const opportunitiesDashboardState = createOpportunitiesDashboardState(contracts);
|
||||
const debugState = createDebugState();
|
||||
const debugState = createDebugState(environment);
|
||||
const columns = createOpportunitiesColumns({
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
formatRevenue: (...args) => opportunitiesFormatters.formatRevenue(...args),
|
||||
@@ -227,11 +226,6 @@ function createContentApplication(overrides = {}) {
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
updateAuthBadge: (...args) => contentModal.updateAuthBadge(...args)
|
||||
});
|
||||
const authSalesCommon = createAuthSalesCommon({
|
||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||
authSalesCommonState,
|
||||
requestTokenRelay: (...args) => authTokenRelay.requestTokenRelay(...args)
|
||||
});
|
||||
const authTokenRelay = createAuthTokenRelay({
|
||||
authSessionState,
|
||||
TOKEN_RELAY_URL: contracts.TOKEN_RELAY_URL,
|
||||
@@ -279,6 +273,7 @@ function createContentApplication(overrides = {}) {
|
||||
setOpportunitiesTableState: (...args) => opportunitiesTable.setOpportunitiesTableState(...args)
|
||||
});
|
||||
const fusionOpportunities = createFusionOpportunities({
|
||||
resetDebugRequests: () => debugState.reset(),
|
||||
OPPORTUNITIES_PAGE_LIMIT: contracts.OPPORTUNITIES_PAGE_LIMIT,
|
||||
OPPORTUNITIES_QUERY_URL: contracts.OPPORTUNITIES_QUERY_URL,
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args),
|
||||
@@ -370,11 +365,14 @@ function createContentApplication(overrides = {}) {
|
||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||
});
|
||||
const opportunityCalendar = createOpportunityCalendar({
|
||||
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
|
||||
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||
createViewIcon: view => contentIcons.createViewIcon(view),
|
||||
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
|
||||
getState: () => opportunitiesTableState.opportunitiesTableState,
|
||||
getItems: () => opportunitiesFilters.getSortedOpportunities(),
|
||||
getVersion: () => opportunitiesQueryState.periodRequestVersion,
|
||||
getRange: () => opportunitiesQueryState.dateRange,
|
||||
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
|
||||
@@ -429,6 +427,13 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const opportunitiesFilters = createOpportunitiesFilters({
|
||||
matchesProductType: item => {
|
||||
const selected = opportunitiesFilterState.selectedProductTypes;
|
||||
if (selected.size === 2) return true;
|
||||
const result = productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
if (!result || result.error) return false;
|
||||
return selected.has(productsDetails.isBooking(result.items) ? "Booking" : "Workload");
|
||||
},
|
||||
matchesActivities: item => activityFilters.matches(item),
|
||||
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||
@@ -496,7 +501,7 @@ function createContentApplication(overrides = {}) {
|
||||
const refreshAll = createRefreshAll({
|
||||
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
|
||||
consumptionPlansState, consumptionTrendState, 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." }),
|
||||
@@ -517,7 +522,8 @@ function createContentApplication(overrides = {}) {
|
||||
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||
}
|
||||
});
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment,
|
||||
retrySession: () => srSessionPreparation.retry() });
|
||||
const srSessionPreparation = createSrSessionPreparation({
|
||||
async onReady() {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
|
||||
@@ -611,13 +617,12 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const productsDetails = createProductsDetails({
|
||||
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
|
||||
openProjection: (products, opportunity, trigger) => rampProjectionView.open(products[0], opportunity, trigger, products),
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
productsContentState,
|
||||
renderOpportunitiesTable: (...args) => opportunitiesTable.renderOpportunitiesTable(...args),
|
||||
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
||||
environment
|
||||
});
|
||||
const rampProjection = createRampProjection();
|
||||
@@ -645,16 +650,6 @@ function createContentApplication(overrides = {}) {
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
environment
|
||||
});
|
||||
const aiSummaryContent = createAiSummaryContent({
|
||||
aiSummaryState,
|
||||
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
||||
logRequestFailure: (...args) => debugContentLog.logRequestFailure(...args),
|
||||
logRequestStart: (...args) => debugContentLog.logRequestStart(...args),
|
||||
logRequestSuccess: (...args) => debugContentLog.logRequestSuccess(...args),
|
||||
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
|
||||
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
|
||||
environment
|
||||
});
|
||||
const customerTableSearch = createCustomerTableSearch({ environment });
|
||||
const installBaseView = createInstallBaseView({
|
||||
tableSorting,
|
||||
@@ -810,13 +805,11 @@ function createContentApplication(overrides = {}) {
|
||||
opportunityWorkloadActions,
|
||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||
authSessionState,
|
||||
authSalesCommonState,
|
||||
opportunitiesQueryState,
|
||||
opportunitiesTableState,
|
||||
opportunitiesFilterState,
|
||||
preferencesState,
|
||||
productsContentState,
|
||||
aiSummaryState,
|
||||
consumptionPlansState,
|
||||
consumptionTrendState,
|
||||
opportunitiesDashboardState,
|
||||
@@ -828,7 +821,6 @@ function createContentApplication(overrides = {}) {
|
||||
contentStyles,
|
||||
authContentSession,
|
||||
currentUser,
|
||||
authSalesCommon,
|
||||
authTokenRelay,
|
||||
authXsrfContent,
|
||||
platformContentMessages,
|
||||
@@ -853,7 +845,6 @@ function createContentApplication(overrides = {}) {
|
||||
rampProjection,
|
||||
workloadForecastUpdate,
|
||||
rampProjectionView,
|
||||
aiSummaryContent,
|
||||
consumptionPlansAction,
|
||||
installBaseAction,
|
||||
installBaseView,
|
||||
|
||||
@@ -81,6 +81,8 @@ function createContentModal({
|
||||
}
|
||||
function openOpportunitiesModal() {
|
||||
ensureExtensionStyles();
|
||||
const oldDebug = document.getElementById(DEBUG_PANEL_ID);
|
||||
if (oldDebug) { oldDebug.hidden = true; oldDebug.textContent = ""; }
|
||||
debugState.debugSecretsVisible = false;
|
||||
|
||||
const existingModal = document.getElementById(MODAL_ID);
|
||||
@@ -101,7 +103,6 @@ function createContentModal({
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("lang", "en");
|
||||
debugState.requestLog = [];
|
||||
authSessionState.tokenRelayRequest = null;
|
||||
authSessionState.consumerTokenServiceRequest = null;
|
||||
authSessionState.accessToken = "";
|
||||
|
||||
@@ -918,51 +918,6 @@
|
||||
color: #312d2a;
|
||||
}
|
||||
|
||||
.opportunities-extension-ai-summary {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 0 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #dedbd7;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #312d2a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.opportunities-extension-ai-summary strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opportunities-extension-ai-summary p {
|
||||
margin: 4px 0 0;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.opportunities-extension-ai-summary-error {
|
||||
color: #b42318;
|
||||
border-color: #d9928b;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary {
|
||||
background: #292827;
|
||||
color: #f6f4f2;
|
||||
border-color: #5f5a55;
|
||||
}
|
||||
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error {
|
||||
color: #ffb4ab;
|
||||
border-color: #a45c55;
|
||||
}
|
||||
|
||||
.opportunities-extension-products-detail-heading strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,22 @@
|
||||
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
|
||||
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }
|
||||
/* Rate Card must fit its drawer; headings may wrap at spaces, never mid-word. */
|
||||
/* Overview reserves nearly half its width for the two primary descriptions. */
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] { table-layout: fixed; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="name"] { width: 24%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="customer"] { width: 24%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="optyNumber"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="owner"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="revenue"] { width: 8%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="closeDate"] { width: 7%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="status"] { width: 5%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="winProbability"] { width: 4%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="stage"] { width: 6%; }
|
||||
.opportunities-extension-modal .opportunities-extension-table > thead > tr > th[data-column="lastUpdateDate"] { width: 8%; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-inline: 6px; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] > thead > tr > th .opportunities-extension-sort-button { min-width: 0; gap: 4px; }
|
||||
.opportunities-extension-modal table.opportunities-extension-table[data-table-layout="standard"] .opportunities-extension-sort-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.opportunities-extension-modal table.opportunities-extension-ratecard-table[data-table-layout="standard"] {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
function createAiSummaryContent({
|
||||
aiSummaryState,
|
||||
invalidateSalesCommonToken,
|
||||
logRequestFailure,
|
||||
logRequestStart,
|
||||
logRequestSuccess,
|
||||
readResponseBodyForDebug,
|
||||
requestTokenRelaySalesCommon,
|
||||
environment
|
||||
}) {
|
||||
const { document, fetch } = environment;
|
||||
function requestGenerateAiSummary(optyNumber) {
|
||||
const key = String(optyNumber);
|
||||
const existing = aiSummaryState.aiSummariesByOptyNumber.get(key);
|
||||
if (existing && existing.status === "loading") {
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const state = { status: "loading", summaryText: "", error: "", promise: null };
|
||||
aiSummaryState.aiSummariesByOptyNumber.set(key, state);
|
||||
state.promise = (async () => {
|
||||
let entry;
|
||||
try {
|
||||
let token = await requestTokenRelaySalesCommon();
|
||||
if (!token) {
|
||||
throw new Error("Authentication is required to generate the summary.");
|
||||
}
|
||||
const path = "/api/sales-common/gen-ai/v1/summarizations/generate";
|
||||
const url = `https://eeho.fa.us2.oraclecloud.com${path}`;
|
||||
const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key };
|
||||
let response;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
entry = logRequestStart({
|
||||
name: "requestGenerateAiSummary", method: "POST", url, headers,
|
||||
body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 }
|
||||
});
|
||||
response = await fetch(url, {
|
||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||
});
|
||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
break;
|
||||
}
|
||||
// A late failure for an old token must not discard a newly refreshed token.
|
||||
invalidateSalesCommonToken(token);
|
||||
if (attempt === 0) {
|
||||
token = await requestTokenRelaySalesCommon();
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error("requestGenerateAiSummary returned an invalid JSON response.");
|
||||
}
|
||||
if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) {
|
||||
throw new Error("requestGenerateAiSummary returned an empty summary.");
|
||||
}
|
||||
state.summaryText = data.summaryText;
|
||||
state.status = "ready";
|
||||
} catch (error) {
|
||||
state.status = "error";
|
||||
state.error = error.message || "Unable to generate the AI summary.";
|
||||
if (entry) {
|
||||
logRequestFailure(entry, state.error);
|
||||
}
|
||||
} finally {
|
||||
// Update only visible summaries, without reopening rows or rebuilding the table.
|
||||
document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => {
|
||||
if (alert.dataset.optyNumber === key) {
|
||||
alert.replaceWith(createAiSummaryAlert(key));
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
return state.promise;
|
||||
}
|
||||
|
||||
function createAiSummaryAlert(optyNumber) {
|
||||
const state = aiSummaryState.aiSummariesByOptyNumber.get(optyNumber);
|
||||
const alert = document.createElement("div");
|
||||
alert.className = "opportunities-extension-ai-summary";
|
||||
alert.dataset.optyNumber = optyNumber;
|
||||
alert.setAttribute("role", "status");
|
||||
alert.setAttribute("aria-live", "polite");
|
||||
alert.setAttribute("aria-busy", String(!state || state.status === "loading"));
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "Summary generated by AI";
|
||||
const description = document.createElement("p");
|
||||
description.textContent = !state || state.status === "loading"
|
||||
? "Generating summary..."
|
||||
: state.status === "error" ? state.error : state.summaryText;
|
||||
alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error"));
|
||||
alert.append(title, description);
|
||||
return alert;
|
||||
}
|
||||
|
||||
return { requestGenerateAiSummary, createAiSummaryAlert };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
function createAiSummaryState() {
|
||||
return {
|
||||
aiSummariesByOptyNumber: new Map()
|
||||
};
|
||||
}
|
||||
@@ -26,11 +26,13 @@ function createDebugContentLog({
|
||||
};
|
||||
|
||||
debugState.requestLog.push(entry);
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function logRequestSuccess(entry, response, responseBody) {
|
||||
if (!debugState.requestLog.includes(entry)) return;
|
||||
entry.status = response.ok ? "success" : "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
@@ -39,14 +41,17 @@ function createDebugContentLog({
|
||||
entry.responseHeaders = headersToObject(response.headers);
|
||||
entry.responseBody = responseBody.value;
|
||||
entry.responseBodyTruncated = responseBody.truncated;
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
function logRequestFailure(entry, errorMessage) {
|
||||
if (!debugState.requestLog.includes(entry)) return;
|
||||
entry.status = "failed";
|
||||
entry.completedAt = new Date();
|
||||
entry.durationMs = entry.completedAt.getTime() - entry.startedAt.getTime();
|
||||
entry.error = errorMessage;
|
||||
debugState.persist(entry);
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
@@ -81,6 +86,7 @@ function createDebugContentLog({
|
||||
};
|
||||
if (existing) Object.assign(existing, normalized);
|
||||
else debugState.requestLog.push(normalized);
|
||||
debugState.persist(existing || normalized);
|
||||
});
|
||||
renderDebugPanel();
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ function createDebugContentView({
|
||||
const categories = [
|
||||
["all", "All"], ["authentication", "Authentication"],
|
||||
["opportunities", "Opportunities"], ["contacts", "Contacts"], ["products", "Products"],
|
||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["serviceRequests", "Service Requests"], ["actions", "Actions"], ["other", "Other"]
|
||||
];
|
||||
let selectedCategory = "all";
|
||||
const expandedIds = new Set();
|
||||
let returnFocus = null;
|
||||
let generation = -1;
|
||||
|
||||
function getRequestCategory(entry) {
|
||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||
@@ -25,7 +26,6 @@ function createDebugContentView({
|
||||
if (["requestQueryAccountContact", "requestPersistContact"].includes(entry.name)) return "contacts";
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||
@@ -54,6 +54,7 @@ function createDebugContentView({
|
||||
closeDebugPanel();
|
||||
return;
|
||||
}
|
||||
debugState.reload();
|
||||
returnFocus = document.activeElement;
|
||||
// Escape nested stacking contexts and inert containers created by child modals.
|
||||
modal.append(panel);
|
||||
@@ -67,6 +68,8 @@ function createDebugContentView({
|
||||
const panel = document.getElementById(DEBUG_PANEL_ID);
|
||||
if (!panel) return;
|
||||
panel.hidden = true;
|
||||
panel.textContent = "";
|
||||
panel.onkeydown = null;
|
||||
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
||||
returnFocus = null;
|
||||
}
|
||||
@@ -78,6 +81,7 @@ function createDebugContentView({
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation !== debugState.generation) { expandedIds.clear(); selectedCategory = "all"; generation = debugState.generation; }
|
||||
for (const item of panel.querySelectorAll("details[data-request-id]")) {
|
||||
const id = item.getAttribute("data-request-id");
|
||||
if (item.open) expandedIds.add(id);
|
||||
@@ -102,7 +106,8 @@ function createDebugContentView({
|
||||
title.textContent = "Debug Requests";
|
||||
|
||||
const summary = document.createElement("p");
|
||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} in the current query history.`;
|
||||
if (debugState.storageError) summary.textContent += ` ${debugState.storageError}`;
|
||||
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
|
||||
if (running) summary.textContent += ` ${running} running.`;
|
||||
heading.append(title, summary);
|
||||
@@ -238,6 +243,9 @@ function createDebugContentView({
|
||||
|
||||
heading.append(name, summaryMeta);
|
||||
|
||||
item.append(heading);
|
||||
const renderDetails = () => {
|
||||
if (!item.open || item.children.length > 1) return;
|
||||
const lines = [
|
||||
`${entry.method} ${entry.url}`,
|
||||
`Started: ${formatDebugTime(entry.startedAt)}`,
|
||||
@@ -256,7 +264,17 @@ function createDebugContentView({
|
||||
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
|
||||
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
|
||||
|
||||
item.append(heading, details);
|
||||
item.append(details);
|
||||
|
||||
};
|
||||
item.addEventListener("toggle", () => {
|
||||
if (item.open) { expandedIds.add(String(entry.id)); renderDetails(); }
|
||||
else {
|
||||
expandedIds.delete(String(entry.id));
|
||||
item.querySelector(".opportunities-extension-debug-details")?.remove();
|
||||
}
|
||||
});
|
||||
renderDetails();
|
||||
list.append(item);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,87 @@
|
||||
function createDebugState() {
|
||||
return {
|
||||
function createDebugState(environment = {}) {
|
||||
let storage;
|
||||
let tabId;
|
||||
try {
|
||||
storage = environment.localStorage;
|
||||
const session = environment.window?.sessionStorage;
|
||||
tabId = session?.getItem("opportunities-extension-debug-tab");
|
||||
if (!tabId) {
|
||||
tabId = environment.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
session?.setItem("opportunities-extension-debug-tab", tabId);
|
||||
}
|
||||
} catch { storage = null; }
|
||||
const prefix = `opportunities-extension-debug:${tabId || "memory"}:`;
|
||||
let timer = null;
|
||||
const pending = new Map();
|
||||
const state = {
|
||||
requestLog: [],
|
||||
debugSecretsVisible: false
|
||||
debugSecretsVisible: false,
|
||||
storageError: "",
|
||||
generation: 0,
|
||||
persist,
|
||||
flush,
|
||||
reload,
|
||||
reset
|
||||
};
|
||||
function persist(entry) {
|
||||
if (!state.requestLog.includes(entry)) return;
|
||||
pending.set(entry.id, entry);
|
||||
try {
|
||||
if (timer === null && environment.setTimeout) timer = environment.setTimeout(flush, 100);
|
||||
else if (!environment.setTimeout) flush();
|
||||
} catch {
|
||||
// Debug persistence must never interrupt the request being recorded.
|
||||
timer = null;
|
||||
flush();
|
||||
}
|
||||
}
|
||||
function flush() {
|
||||
if (timer !== null) environment.clearTimeout?.(timer);
|
||||
timer = null;
|
||||
if (!storage) { pending.clear(); return; }
|
||||
try {
|
||||
// Write only changed records, never stringify all response bodies per request.
|
||||
for (const [id, entry] of pending) storage.setItem(`${prefix}${id}`, JSON.stringify(entry));
|
||||
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: state.requestLog.length }));
|
||||
state.storageError = "";
|
||||
} catch {
|
||||
state.storageError = "Local storage is unavailable or full. Requests remain available in memory for this session.";
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
function reload() {
|
||||
flush();
|
||||
if (!storage || state.storageError) return;
|
||||
try {
|
||||
const index = JSON.parse(storage.getItem(`${prefix}index`) || "null");
|
||||
if (!index || index.version !== 1 || index.count !== state.requestLog.length) return;
|
||||
const records = state.requestLog.map(entry => {
|
||||
const saved = JSON.parse(storage.getItem(`${prefix}${entry.id}`) || "null");
|
||||
if (!saved || saved.id !== entry.id) throw new Error("Incomplete debug record");
|
||||
return { ...saved, startedAt: new Date(saved.startedAt), completedAt: saved.completedAt ? new Date(saved.completedAt) : null };
|
||||
});
|
||||
// Preserve the handles held by requests which are still in flight.
|
||||
records.forEach((record, i) => Object.assign(state.requestLog[i], record));
|
||||
} catch { state.storageError = "Saved debug data could not be read. Showing this session's requests from memory."; }
|
||||
}
|
||||
function reset() {
|
||||
if (timer !== null) environment.clearTimeout?.(timer);
|
||||
timer = null;
|
||||
pending.clear();
|
||||
state.requestLog = [];
|
||||
state.debugSecretsVisible = false;
|
||||
state.storageError = "";
|
||||
state.generation++;
|
||||
try {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
storage.setItem(`${prefix}index`, JSON.stringify({ version: 1, count: 0 }));
|
||||
} catch { state.storageError = "Local storage is unavailable. Requests will be kept in memory for this session."; }
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function createActivityProgress({ MODAL_ID, environment }) {
|
||||
function createActivityProgress({ MODAL_ID, environment, retrySession = () => {} }) {
|
||||
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
const entries = new Map();
|
||||
function update(key, state, completed = 0, total = null) {
|
||||
@@ -29,6 +29,15 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
|
||||
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
|
||||
if (key === "session" && state === "error") {
|
||||
const retry = document.createElement("button");
|
||||
retry.type = "button";
|
||||
retry.className = "opportunities-extension-session-retry";
|
||||
retry.textContent = "Retry";
|
||||
retry.setAttribute("aria-label", "Retry Service Request session initialization");
|
||||
retry.addEventListener("click", () => { retry.disabled = true; retrySession(); });
|
||||
entry.badge.append(retry);
|
||||
}
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
|
||||
}
|
||||
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
|
||||
.opportunities-calendar-day-cards { position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; padding-bottom: 4px; }
|
||||
.opportunities-calendar-day-card { display: block; width: calc(100% - 8px); padding: 0; text-align: left; font: inherit; }
|
||||
.opportunities-calendar-day-card:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
|
||||
/* A single vertical sequence of months, with a separate scroll area per day. */
|
||||
.opportunities-extension-calendar .fc .fc-multimonth { overflow-y: auto; overflow-x: hidden; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-month { width: 100%; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth-daygrid-table { height: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tbody { height: auto; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-body tr { height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-frame { position: relative; height: var(--calendar-week-height, 190px); min-height: var(--calendar-week-height, 190px); }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-events {
|
||||
position: absolute; inset: 27px 0 0; overflow-y: auto; overflow-x: hidden;
|
||||
margin: 0; padding-bottom: 4px; scrollbar-width: thin;
|
||||
}
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-event-harness { position: relative !important; top: auto !important; left: auto !important; right: auto !important; }
|
||||
.opportunities-extension-calendar .fc .fc-multimonth .fc-daygrid-day-bg { pointer-events: none; }
|
||||
.opportunities-extension-calendar .fc { min-width: 620px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
|
||||
@@ -103,3 +119,12 @@
|
||||
.opportunities-calendar-day-count { top: 25px; }
|
||||
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
|
||||
}
|
||||
.opportunities-view-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--oe-table-heading-border); flex-wrap: wrap; }
|
||||
.opportunities-view-bar .opportunities-extension-view-tabs { border-bottom: 0; }
|
||||
.opportunities-product-type-filters { display: flex; gap: 8px; margin-left: auto; padding: 4px 0; }
|
||||
.opportunities-product-type-filter { border: 1px solid #8f8a85; border-radius: 12px; padding: 3px 9px; background: transparent; color: inherit; font: 600 12px/16px Arial, sans-serif; cursor: pointer; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #714016; background: #fff0df; border-color: #c58b50; }
|
||||
.opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #63358a; background: #f0e8f7; border-color: #bba5cf; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Booking"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-product-type-filter[aria-pressed="true"][data-type="Workload"] { color: #ead5fc; background: #43334f; border-color: #9372ac; }
|
||||
.opportunities-product-type-filter:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
|
||||
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
|
||||
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
|
||||
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
|
||||
productTypeSelection, onProductTypeChange }) {
|
||||
const { document } = environment;
|
||||
let elements = null;
|
||||
let calendar = null;
|
||||
@@ -10,34 +11,49 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
let details = null;
|
||||
let lastEvents = "";
|
||||
let dayCounts = new Map();
|
||||
let dayEvents = new Map();
|
||||
const dayBadges = new Map();
|
||||
let resizeObserver = null;
|
||||
let layoutFrame = null;
|
||||
let eventLimit = 2;
|
||||
function visibleEventLimit(height, weeks, headerHeight) {
|
||||
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
|
||||
}
|
||||
let positionToday = false;
|
||||
function scheduleLayout() {
|
||||
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
|
||||
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
|
||||
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
|
||||
}
|
||||
const win = environment.window;
|
||||
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
|
||||
layoutFrame = win.requestAnimationFrame(() => {
|
||||
layoutFrame = null;
|
||||
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
|
||||
calendar.updateSize();
|
||||
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
|
||||
const events = frame?.querySelector(".fc-daygrid-day-events");
|
||||
if (!frame || !events) return;
|
||||
const scroller = frame.closest(".fc-scroller");
|
||||
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
|
||||
if (!scroller || !weeks) return;
|
||||
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
|
||||
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
|
||||
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
|
||||
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
|
||||
if (calendar && selectedView === "calendar") {
|
||||
calendar.updateSize();
|
||||
if (positionToday && !elements.calendarHost.hidden) {
|
||||
const scroller = elements.calendarHost.querySelector(".fc-multimonth");
|
||||
if (!scroller?.clientHeight) return;
|
||||
const today = dateKey(new Date());
|
||||
const range = getRange();
|
||||
const cell = range && today >= range.startCloseDate.slice(0, 10) && today < range.endCloseDate.slice(0, 10)
|
||||
? elements.calendarHost.querySelector(`[data-date="${today}"]:not(.fc-day-other)`)
|
||||
: null;
|
||||
if (cell) {
|
||||
const first = new Date(`${today}T12:00:00`);
|
||||
first.setDate(first.getDate() - first.getDay() - 7);
|
||||
const last = new Date(first);
|
||||
last.setDate(last.getDate() + 20);
|
||||
const start = dateKey(first), end = dateKey(last);
|
||||
// A week crossing a month boundary has two rendered rows. Include both.
|
||||
const rows = [...scroller.querySelectorAll(".fc-daygrid-body tr")].filter(row =>
|
||||
[...row.querySelectorAll("[data-date]:not(.fc-day-other)")].some(day => {
|
||||
const date = day.getAttribute("data-date");
|
||||
return date >= start && date <= end;
|
||||
}));
|
||||
const headings = [...new Set(rows.map(row => row.closest(".fc-multimonth-month")?.querySelector(".fc-multimonth-header")))].filter(Boolean);
|
||||
const headerSpace = headings.reduce((sum, header) => sum + header.getBoundingClientRect().height, 0);
|
||||
const height = Math.max(48, Math.floor((scroller.clientHeight - headerSpace - 20) / Math.max(1, rows.length)));
|
||||
elements.calendarHost.style.setProperty("--calendar-week-height", `${height}px`);
|
||||
const target = rows[0] || cell;
|
||||
const inset = (headings[0]?.getBoundingClientRect().height || 0) + 8;
|
||||
scroller.scrollTop += target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - inset;
|
||||
} else scroller.scrollTop = 0;
|
||||
positionToday = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function dateKey(date) {
|
||||
@@ -64,6 +80,21 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
badge.setAttribute("data-empty", String(count === 0));
|
||||
}
|
||||
function renderDayCards(container, key) {
|
||||
container.textContent = "";
|
||||
for (const event of dayEvents.get(key) || []) {
|
||||
const item = event.extendedProps.item;
|
||||
const button = node("button", `opportunities-calendar-day-card fc-daygrid-event ${event.classNames.join(" ")}`);
|
||||
button.type = "button";
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
|
||||
card.append(node("strong", "", event.title), node("span", "", summary));
|
||||
button.title = `${event.title}\n${summary}`;
|
||||
button.append(card);
|
||||
button.addEventListener("click", () => showDetails(item, button));
|
||||
container.append(button);
|
||||
}
|
||||
}
|
||||
function node(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
@@ -145,29 +176,10 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
content.append(identity, summary, badges);
|
||||
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
|
||||
}
|
||||
function showDayOpportunities({ date, allSegs, jsEvent }) {
|
||||
jsEvent.preventDefault();
|
||||
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
|
||||
const content = node("div", "opportunities-calendar-day-list");
|
||||
const controls = [];
|
||||
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
|
||||
for (const event of events.values()) {
|
||||
const item = event.extendedProps.item;
|
||||
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
|
||||
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
|
||||
button.append(node("strong", "", event.title), node("span", "", summary));
|
||||
button.title = `${event.title}\n${summary}`;
|
||||
button.addEventListener("click", () => showDetails(item, trigger));
|
||||
controls.push(button); content.append(button);
|
||||
}
|
||||
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} ${events.size === 1 ? "opportunity" : "opportunities"}`, content, controls, trigger);
|
||||
// A truthy non-string result prevents FullCalendar from opening its measured popover.
|
||||
return true;
|
||||
}
|
||||
function mount(body, tableSurface) {
|
||||
resizeObserver?.disconnect();
|
||||
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
|
||||
layoutFrame = null; eventLimit = 2;
|
||||
layoutFrame = null;
|
||||
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
|
||||
dayBadges.clear(); dayCounts.clear();
|
||||
closeDetails(false);
|
||||
@@ -194,7 +206,26 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
});
|
||||
tabs[key] = tab; tablist.append(tab);
|
||||
}
|
||||
workspace.append(tablist, tableSurface, surface); body.append(workspace);
|
||||
const viewbar = node("div", "opportunities-view-bar");
|
||||
viewbar.append(tablist);
|
||||
if (productTypeSelection) {
|
||||
const filters = node("div", "opportunities-product-type-filters");
|
||||
filters.setAttribute("role", "group"); filters.setAttribute("aria-label", "Opportunity type");
|
||||
for (const type of ["Booking", "Workload"]) {
|
||||
const button = node("button", "opportunities-product-type-filter", type);
|
||||
button.type = "button";
|
||||
button.setAttribute("data-type", type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
button.addEventListener("click", () => {
|
||||
if (productTypeSelection.has(type)) productTypeSelection.delete(type); else productTypeSelection.add(type);
|
||||
button.setAttribute("aria-pressed", String(productTypeSelection.has(type)));
|
||||
onProductTypeChange();
|
||||
});
|
||||
filters.append(button);
|
||||
}
|
||||
viewbar.append(filters);
|
||||
}
|
||||
workspace.append(viewbar, tableSurface, surface); body.append(workspace);
|
||||
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
|
||||
if (environment.window?.ResizeObserver) {
|
||||
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
|
||||
@@ -204,6 +235,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
function selectView(view) {
|
||||
if (!elements || !["overview", "calendar"].includes(view)) return;
|
||||
if (view === "calendar" && selectedView !== "calendar") positionToday = true;
|
||||
closeDetails(false); closeSrPopover(); selectedView = view;
|
||||
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
|
||||
for (const [key, tab] of Object.entries(elements.tabs)) {
|
||||
@@ -217,41 +249,57 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
if (!elements || selectedView !== "calendar") return;
|
||||
const { surface, message, calendarHost, undated } = elements;
|
||||
surface.setAttribute("aria-busy", String(state.status === "loading"));
|
||||
if (["loading", "error", "empty", "idle"].includes(state.status)) {
|
||||
if (["loading", "error", "idle"].includes(state.status)) {
|
||||
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
|
||||
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
|
||||
}
|
||||
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
|
||||
calendarHost.hidden = false;
|
||||
const events = eventsFor(items);
|
||||
const range = getRange();
|
||||
if (!range) { calendarHost.hidden = true; return; }
|
||||
const start = range.startCloseDate.slice(0, 10);
|
||||
const end = range.endCloseDate.slice(0, 10);
|
||||
const visibleRange = { start, end }; // Fiscal ranges end at the next period's start (exclusive).
|
||||
const events = eventsFor(items).filter(event => event.start >= start && event.start < end);
|
||||
dayCounts = new Map();
|
||||
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
|
||||
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
|
||||
const anchor = initialDate(state.items);
|
||||
dayEvents = new Map();
|
||||
for (const event of events) {
|
||||
dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
|
||||
if (!dayEvents.has(event.start)) dayEvents.set(event.start, []);
|
||||
dayEvents.get(event.start).push(event);
|
||||
}
|
||||
for (const { badge, key, cards } of dayBadges.values()) { updateDayBadge(badge, key); renderDayCards(cards, key); }
|
||||
const anchor = start;
|
||||
if (!calendar) {
|
||||
try {
|
||||
library ||= loadLibrary();
|
||||
calendar = new library.Calendar(calendarHost, {
|
||||
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
|
||||
initialView: "referenceMonths", initialDate: anchor, height: "100%", fixedWeekCount: false,
|
||||
views: { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } },
|
||||
visibleRange, validRange: visibleRange, showNonCurrentDates: false,
|
||||
multiMonthMaxColumns: 1, multiMonthMinWidth: 0,
|
||||
multiMonthTitleFormat: { month: "long", year: "numeric" },
|
||||
datesSet: scheduleLayout,
|
||||
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
|
||||
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
|
||||
buttonText: { today: "Today", month: "Month", week: "Week" },
|
||||
buttonHints: { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" },
|
||||
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
|
||||
moreLinkHint: count => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
|
||||
moreLinkClick: showDayOpportunities,
|
||||
headerToolbar: false,
|
||||
dayMaxEvents: false, dayMaxEventRows: false, displayEventTime: false,
|
||||
weekNumbers: true,
|
||||
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
|
||||
dayCellDidMount: ({ el, date }) => {
|
||||
const badge = node("span", "opportunities-calendar-day-count");
|
||||
const key = dateKey(date);
|
||||
updateDayBadge(badge, key);
|
||||
el.querySelector(".fc-daygrid-day-frame").append(badge);
|
||||
dayBadges.set(el, { badge, key });
|
||||
const cards = node("div", "opportunities-calendar-day-cards");
|
||||
cards.tabIndex = 0;
|
||||
cards.setAttribute("role", "region");
|
||||
cards.setAttribute("aria-label", `Opportunities for ${formatDate(key)}`);
|
||||
renderDayCards(cards, key);
|
||||
el.querySelector(".fc-daygrid-day-frame").append(badge, cards);
|
||||
dayBadges.set(el, { badge, key, cards });
|
||||
},
|
||||
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
|
||||
eventOrder: "title", eventDisplay: "block", events,
|
||||
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.get(el)?.cards.remove(); dayBadges.delete(el); },
|
||||
// MultiMonth imposes its own event limit. Render all cards in our scrollable day region.
|
||||
eventOrder: "title", eventDisplay: "none", events,
|
||||
eventContent: ({ event }) => {
|
||||
const item = event.extendedProps.item;
|
||||
const card = node("div", "opportunities-calendar-event-content");
|
||||
@@ -274,7 +322,15 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
const signature = JSON.stringify(events);
|
||||
calendar.batchRendering(() => {
|
||||
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
|
||||
if (anchoredVersion !== getVersion()) {
|
||||
positionToday = true;
|
||||
calendar.setOption("views", { referenceMonths: { type: "multiMonth", visibleRange, dayMaxEvents: false, dayMaxEventRows: false } });
|
||||
calendar.setOption("visibleRange", visibleRange);
|
||||
calendar.setOption("validRange", visibleRange);
|
||||
calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false);
|
||||
const scroller = calendarHost.querySelector?.(".fc-multimonth");
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
}
|
||||
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
|
||||
});
|
||||
const missing = items.filter(item => !closeDate(item.EffectiveDate));
|
||||
@@ -288,5 +344,5 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
||||
}
|
||||
scheduleLayout();
|
||||
}
|
||||
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
|
||||
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ function createOpportunitiesColumns({ formatOracleResponseDate, formatRevenue, f
|
||||
{ key: "revenue", label: "Amount", value: (item) => getNestedValue(item, ["PrimaryRevenue", "RevnAmount"]), display: formatRevenue },
|
||||
{ key: "closeDate", label: "Close Date", value: (item) => item.EffectiveDate, display: (item) => formatOracleResponseDate(item.EffectiveDate) },
|
||||
{ key: "status", label: "Status", value: (item) => item.StatusCode, display: (item) => item.StatusCode },
|
||||
{ key: "winProbability", label: "Win Probability", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "winProbability", label: "Prob.", value: (item) => getNestedValue(item, ["PrimaryRevenue", "WinProb"]), display: formatWinProbability },
|
||||
{ key: "stage", label: "Stage", value: (item) => item.ForecastGroup_c, display: (item) => item.ForecastGroup_c },
|
||||
{ key: "lastUpdateDate", label: "Last Update Date", value: (item) => item.LastUpdateDate, display: (item) => formatOracleResponseDate(item.LastUpdateDate, true) }
|
||||
];
|
||||
|
||||
@@ -23,6 +23,7 @@ function createOpportunitiesController({
|
||||
}) {
|
||||
async function requestOpportunitiesForPeriod(period) {
|
||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||
opportunitiesQueryState.dateRange = dateRange;
|
||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createOpportunitiesFilterState({ STAGE_OPTIONS, STATUS_OPTIONS } = {}) {
|
||||
return {
|
||||
selectedProductTypes: new Set(["Booking", "Workload"]),
|
||||
selectedStages: new Set(STAGE_OPTIONS),
|
||||
selectedStatuses: new Set(STATUS_OPTIONS),
|
||||
selectedCustomerKeys: new Set(),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createOpportunitiesFilters({
|
||||
saveFilterPreferences,
|
||||
stringifySearchValue,
|
||||
matchesActivities,
|
||||
matchesProductType = () => true,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
@@ -345,7 +346,8 @@ function createOpportunitiesFilters({
|
||||
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||
&& matchesTableSearch(item, normalizedSearch)
|
||||
&& matchesActivities(item);
|
||||
&& matchesActivities(item)
|
||||
&& matchesProductType(item);
|
||||
});
|
||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createOpportunitiesQueryState() {
|
||||
return {
|
||||
periodRequestVersion: 0
|
||||
periodRequestVersion: 0,
|
||||
dateRange: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
let pending = null;
|
||||
function refresh(period) {
|
||||
if (pending) return pending;
|
||||
@@ -13,7 +13,6 @@ function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, res
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||
resetRelatedData();
|
||||
pending = (async () => {
|
||||
await activityCache.invalidate();
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
|
||||
let pending = null;
|
||||
let failed = false;
|
||||
function ensure() {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
failed = false;
|
||||
try {
|
||||
await ensureCurrentUser();
|
||||
if (!allowsCrmActivities()) return;
|
||||
reportProgress("running");
|
||||
const result = await sendRequest();
|
||||
// Navigation establishes SR cookies independently of the SPA identity.
|
||||
// Consume user-dependent data only once both preparations have finished.
|
||||
const [, result] = await Promise.all([ensureCurrentUser(), sendRequest()]);
|
||||
appendLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
|
||||
if (!allowsCrmActivities()) { reportProgress("idle"); return; }
|
||||
reportProgress("complete");
|
||||
await onReady();
|
||||
} catch {
|
||||
failed = true;
|
||||
reportProgress("error");
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
}
|
||||
return { ensure };
|
||||
function retry() {
|
||||
if (failed) pending = null;
|
||||
return ensure();
|
||||
}
|
||||
return { ensure, retry };
|
||||
}
|
||||
|
||||
@@ -625,3 +625,9 @@
|
||||
}
|
||||
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
|
||||
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }
|
||||
.opportunities-extension-session-retry {
|
||||
margin-left: 8px; padding: 2px 6px; border: 1px solid currentColor;
|
||||
border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer;
|
||||
}
|
||||
.opportunities-extension-session-retry:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-session-retry:disabled { opacity: .6; cursor: wait; }
|
||||
|
||||
@@ -171,16 +171,16 @@ function createOpportunitiesTable({
|
||||
const productsResult = item.OptyNumber && productsContentState.opptyProductsByNumber.get(String(item.OptyNumber));
|
||||
if (productsResult) {
|
||||
productActions.append(createProductsCountButton(item.OptyNumber, productsResult));
|
||||
const rampBadge = createProductsRampBadge(productsResult);
|
||||
const rampBadge = createProductsRampBadge(productsResult, item);
|
||||
if (rampBadge) productActions.append(rampBadge);
|
||||
}
|
||||
const projectionIndicator = createProjectionIndicator(productsResult, item,
|
||||
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
|
||||
if (projectionIndicator) productActions.append(projectionIndicator);
|
||||
const srBadge = createCrmServiceRequestBadge(item.OptyNumber);
|
||||
if (srBadge) productActions.append(srBadge);
|
||||
const actionsBadge = createWorkloadActionsBadge(item.OptyNumber);
|
||||
if (actionsBadge) productActions.append(actionsBadge);
|
||||
const projectionIndicator = createProjectionIndicator(productsResult, item,
|
||||
productsContentState.expandedProductOptyNumbers.has(String(item.OptyNumber)));
|
||||
if (projectionIndicator) productActions.append(projectionIndicator);
|
||||
nameCellContent.append(opportunityLink, productActions);
|
||||
cell.append(nameCellContent);
|
||||
} else if (column.key === "optyNumber" && item.OptyNumber) {
|
||||
|
||||
@@ -129,7 +129,7 @@ function createOrgChartView({ environment, tableSorting, model, loadLayout, icon
|
||||
person = entry.nodes.find(node => node.id === person.id) || person;
|
||||
const overlay = el("div", "oe-org-confirm"), panel = el("section", "opportunities-extension-action-form-dialog oe-org-dialog oe-contact-opportunities");
|
||||
panel.setAttribute("role", "dialog"); panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "oe-contact-opportunities-title");
|
||||
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities — " + person.name); title.id = "oe-contact-opportunities-title";
|
||||
const header = el("header", "oe-org-editor-header"), title = el("h3", "", "Opportunities | " + person.name); title.id = "oe-contact-opportunities-title";
|
||||
let closed = false, offset = 0, partyNumber = person.PartyNumber;
|
||||
const current = () => !closed && active === entry;
|
||||
function finish() { closed = true; overlay.remove(); entry.confirmation = null; entry.contactCleanup = null; dialog.inert = false; trigger.focus(); }
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
function createProductsDetails({
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
setBadgeContent,
|
||||
createAiSummaryAlert,
|
||||
createProjectionIndicator,
|
||||
openProjection,
|
||||
formatOracleResponseDate,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable,
|
||||
requestGenerateAiSummary,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createProductsRampBadge(productsResult) {
|
||||
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
|
||||
function isBooking(products) {
|
||||
return Boolean(products?.length) && products.every(item => !String(item?.WorkloadName_c ?? "").trim() || String(item.WorkloadName_c).trim() === "-");
|
||||
}
|
||||
function createProductsRampBadge(productsResult, opportunity) {
|
||||
const products = Array.isArray(productsResult.items) ? productsResult.items : [];
|
||||
if (isBooking(products)) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-products-ramp";
|
||||
badge.setAttribute("data-booking", "true");
|
||||
badge.textContent = "Booking";
|
||||
badge.title = "Booking: no product has a Workload assigned.";
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
return badge;
|
||||
}
|
||||
const values = products
|
||||
.map(item => item && item.RampMonths_c)
|
||||
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
|
||||
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
|
||||
if (!values.length) return null;
|
||||
const badge = document.createElement("span");
|
||||
const badge = document.createElement("button");
|
||||
badge.type = "button";
|
||||
badge.className = "opportunities-extension-products-ramp";
|
||||
badge.setAttribute("data-missing", String(values.includes("N/A")));
|
||||
setBadgeContent(badge, "ramp", values.join(" | "));
|
||||
badge.title = `Ramp Months: ${values.join(", ")}`;
|
||||
badge.setAttribute("aria-label", badge.title);
|
||||
badge.setAttribute("aria-haspopup", "dialog");
|
||||
badge.addEventListener("click", event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
openProjection(productsResult.items, opportunity, badge);
|
||||
});
|
||||
return badge;
|
||||
}
|
||||
|
||||
@@ -29,7 +47,6 @@ function createProductsDetails({
|
||||
const number = String(optyNumber);
|
||||
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
|
||||
productsContentState.expandedProductOptyNumbers.add(number);
|
||||
requestGenerateAiSummary(number);
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
@@ -86,7 +103,6 @@ function createProductsDetails({
|
||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||
heading.append(title, summary);
|
||||
panel.append(heading);
|
||||
panel.append(createAiSummaryAlert(optyNumber));
|
||||
|
||||
if (productsResult && productsResult.error) {
|
||||
const error = document.createElement("p");
|
||||
@@ -181,5 +197,5 @@ function createProductsDetails({
|
||||
return Number.isFinite(number) ? `${number}%` : "-";
|
||||
}
|
||||
|
||||
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||
return { isBooking, expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
||||
}
|
||||
function createIndicator(product, opportunity) {
|
||||
const result = compare(product);
|
||||
if (!product?.workload) {
|
||||
const disabled = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
|
||||
disabled.type = "button";
|
||||
disabled.disabled = true;
|
||||
disabled.title = "No Workload is available for this product.";
|
||||
disabled.setAttribute("aria-label", disabled.title);
|
||||
disabled.append(createAlertIcon());
|
||||
return disabled;
|
||||
}
|
||||
if (result.status !== "divergent") {
|
||||
const text = node("span", "opportunities-extension-projection-status", result.status === "matched" ? "OK" : "-");
|
||||
text.title = result.status === "matched" ? "Monthly forecast matches the ramp calculation." : result.reason;
|
||||
@@ -55,10 +64,10 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
||||
});
|
||||
return indicator;
|
||||
}
|
||||
function open(product, opportunity, trigger) {
|
||||
function open(product, opportunity, trigger, products = []) {
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
const result = compare(product);
|
||||
if (!host || host.hidden || result.status !== "divergent") return;
|
||||
if (!host || host.hidden || !product) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
@@ -78,12 +87,25 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
||||
dismiss.addEventListener("click", () => close());
|
||||
header.append(heading, dismiss);
|
||||
const body = node("div", "opportunities-extension-projection-body");
|
||||
let productSelect = null;
|
||||
if (products.length > 1) {
|
||||
const label = node("label", "", "Product ");
|
||||
productSelect = node("select", "opportunities-extension-projection-product");
|
||||
productSelect.setAttribute("aria-label", "Product");
|
||||
products.forEach((item, index) => {
|
||||
const option = node("option", "", `${item.ProdGroupName || "Product"} | ${item.WorkloadName_c || item.RevnId || index + 1}`);
|
||||
option.value = String(index); option.selected = item === product;
|
||||
productSelect.append(option);
|
||||
});
|
||||
productSelect.addEventListener("change", () => open(products[Number(productSelect.value)], opportunity, trigger, products));
|
||||
label.append(productSelect); body.append(label);
|
||||
}
|
||||
const details = node("dl", "opportunities-extension-projection-context");
|
||||
const currency = product.RevnAmountCurcyCode || "USD";
|
||||
for (const [label, value] of [
|
||||
["Customer", opportunity.CustomerAccount?.PartyUniqueName || "-"], ["Product", product.ProdGroupName || "-"],
|
||||
["Annual value", formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp)],
|
||||
["Consumption start", formatDate(result.start)], ["First month", `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
|
||||
["Annual value", result.annual == null ? "-" : formatAmount(result.annual, currency)], ["Ramp months", String(result.ramp ?? "-")],
|
||||
["Consumption start", result.start ? formatDate(result.start) : "-"], ["First month", result.firstMonthDays == null ? "-" : `${result.firstMonthDays}/${result.firstMonthTotalDays} days`]
|
||||
]) details.append(node("dt", "", label), node("dd", "", value));
|
||||
body.append(details);
|
||||
const summary = node("p", "opportunities-extension-projection-summary", `${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"} with a difference`);
|
||||
@@ -128,6 +150,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
||||
body.append(errorMessage);
|
||||
const footer = node("footer", "opportunities-extension-projection-footer");
|
||||
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
|
||||
updateButton.disabled = result.status !== "divergent";
|
||||
updateButton.type = "button";
|
||||
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
|
||||
dialog.append(header, body, footer);
|
||||
@@ -212,7 +235,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [dismiss, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => !control.disabled);
|
||||
const controls = [dismiss, productSelect, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => control && !control.disabled);
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
||||
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
.opportunities-extension-projection-alert svg { display: block; flex: none; width: 19px; height: 19px; }
|
||||
.opportunities-extension-cell-actions > .opportunities-extension-opportunity-projection-alert { width: 20px; height: 20px; flex-basis: 20px; }
|
||||
.opportunities-extension-projection-alert:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-modal .opportunities-extension-projection-alert:disabled { color: #8f8a85; opacity: .55; cursor: not-allowed; background: transparent; }
|
||||
.opportunities-extension-products-ramp { cursor: pointer; font-family: inherit; }
|
||||
.opportunities-extension-products-ramp[data-booking="true"] { cursor: default; color: #714016; background: #fff0df; border-color: #c58b50; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-products-ramp[data-booking="true"] { color: #ffdbb5; background: #4b321e; border-color: #c99760; }
|
||||
.opportunities-extension-products-ramp:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-projection-product { max-width: 100%; margin: 0 0 16px; padding: 8px; font: inherit; color: inherit; background: inherit; }
|
||||
.opportunities-extension-action-form-dialog.opportunities-extension-projection-dialog { width: min(850px, 100%); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.opportunities-extension-projection-dialog > header { flex: 0 0 auto; padding: 18px 20px; }
|
||||
.opportunities-extension-projection-dialog .opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -8,6 +8,7 @@ function createFusionOpportunities({
|
||||
maskToken,
|
||||
getOpportunityTypeValues,
|
||||
readResponseBodyForDebug,
|
||||
resetDebugRequests = () => {},
|
||||
environment
|
||||
}) {
|
||||
const { fetch } = environment;
|
||||
@@ -35,6 +36,8 @@ function createFusionOpportunities({
|
||||
if (!isCurrentPeriodRequest(requestVersion)) return null;
|
||||
const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber);
|
||||
const requestBody = JSON.stringify(payload);
|
||||
// One history per query, preserving all pages of the same result set.
|
||||
if (page === 1) resetDebugRequests();
|
||||
const requestEntry = logRequestStart({
|
||||
name: "requestOpportunities",
|
||||
method: "POST",
|
||||
|
||||
@@ -85,7 +85,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let session = sessions.get(key);
|
||||
if (!session) {
|
||||
const details = {
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
|
||||
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests",
|
||||
active: false
|
||||
};
|
||||
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
|
||||
@@ -102,8 +102,8 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
let tab;
|
||||
try {
|
||||
tab = await session.tab;
|
||||
const deadline = Date.now() + 60000;
|
||||
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
|
||||
const deadline = Date.now() + 70000;
|
||||
for (let attempt = 0; attempt < 280 && Date.now() < deadline; attempt += 1) {
|
||||
const pendingCookies = initialize && attempt === 0
|
||||
? sessionCookies(sender) : waitForSessionCookies(session, sender);
|
||||
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
|
||||
@@ -122,7 +122,7 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
if (error.httpStatus !== 401) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
|
||||
throw new Error("Service Request session could not be established after 70 seconds. Sign in to Oracle and retry.");
|
||||
} catch (error) {
|
||||
error.debugRequests = logs;
|
||||
throw error;
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl }) {
|
||||
function createSpaCurrentUser({ fetchJson, fetchInTab, openSessionTab, waitForSession, closeSessionTab, sessionUrl, sessionStorage }) {
|
||||
const pending = new Map();
|
||||
const identities = new Map();
|
||||
function requestCurrentUser(sender) {
|
||||
const key = JSON.stringify([sender?.tab?.windowId ?? null, sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
// Windows share cookies; Firefox containers and private sessions do not.
|
||||
const key = JSON.stringify([sender?.tab?.cookieStoreId ?? "", Boolean(sender?.tab?.incognito)]);
|
||||
if (identities.has(key)) return Promise.resolve({ ok: true, user: identities.get(key), debugRequests: [] });
|
||||
if (!pending.has(key)) {
|
||||
pending.set(key, loadCurrentUser(sender).finally(() => pending.delete(key)));
|
||||
pending.set(key, (async () => {
|
||||
const storageKey = `opportunities-extension-current-user:${key}`;
|
||||
try {
|
||||
const stored = (await sessionStorage?.get(storageKey))?.[storageKey];
|
||||
if (stored && typeof stored.id === "string" && stored.id.trim() && typeof stored.name === "string" && stored.name.trim()
|
||||
&& Array.isArray(stored.roles) && stored.roles.every(role => typeof role === "string" && role.trim())) {
|
||||
identities.set(key, stored);
|
||||
return { ok: true, user: stored, debugRequests: [] };
|
||||
}
|
||||
} catch { /* Memory cache still works if session storage is unavailable. */ }
|
||||
const result = await loadCurrentUser(sender);
|
||||
identities.set(key, result.user);
|
||||
try { await sessionStorage?.set({ [storageKey]: result.user }); } catch { /* Keep the successful in-memory identity. */ }
|
||||
return result;
|
||||
})().finally(() => pending.delete(key)));
|
||||
}
|
||||
return pending.get(key);
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.listeners = {};
|
||||
this.classes = new Set();
|
||||
this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) };
|
||||
}
|
||||
append(...children) { this.children.push(...children); }
|
||||
setAttribute(name, value) { this.attributes[name] = value; }
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; }
|
||||
replaceWith(element) { this.replacement = element; }
|
||||
click() { this.listeners.click({ preventDefault() {}, stopPropagation() {} }); }
|
||||
}
|
||||
|
||||
function setup(mockSalesToken = true) {
|
||||
const calls = [];
|
||||
let now = Date.now();
|
||||
class TestDate extends Date {
|
||||
constructor(...args) { super(...(args.length ? args : [now])); }
|
||||
static now() { return now; }
|
||||
}
|
||||
let visibleAlerts = [];
|
||||
const context = {
|
||||
document: {
|
||||
createElement: (tag) => new Element(tag),
|
||||
createElementNS: (_, tag) => new Element(tag),
|
||||
querySelectorAll: () => visibleAlerts
|
||||
},
|
||||
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
||||
Intl, Date: TestDate, Map, Set
|
||||
};
|
||||
const app = loadApplication("content", context);
|
||||
app.authSessionState.accessToken = "test-bearer-secret";
|
||||
app.authXsrfContent.getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
|
||||
app.platformContentMessages.refreshXsrfCookie = async () => ({ refreshed: false });
|
||||
if (mockSalesToken) app.authSalesCommon.requestTokenRelaySalesCommon = async () => "test-sales-common-secret";
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||
return {
|
||||
...app.aiSummaryContent, ...app.productsDetails, ...app.aiSummaryState,
|
||||
...app.productsContentState, ...app.debugContentLog, ...app.debugState,
|
||||
requestTokenRelay: app.authTokenRelay.requestTokenRelay,
|
||||
requestTokenRelaySalesCommon: app.authSalesCommon.requestTokenRelaySalesCommon,
|
||||
getAccessToken: () => app.authSessionState.accessToken,
|
||||
calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; }
|
||||
};
|
||||
}
|
||||
|
||||
function json(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
async function run() {
|
||||
const h = setup();
|
||||
const products = { items: [{ ProdGroupName: "Cloud", RevnAmount: "10000", WinProb: 30 }] };
|
||||
h.opptyProductsByNumber.set("A1", products);
|
||||
h.createProductsCountButton("A1", products).click();
|
||||
const first = h.aiSummariesByOptyNumber.get("A1").promise;
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.requestGenerateAiSummary("A1"), first);
|
||||
assert.equal(h.calls.length, 1, "in-flight requests are deduplicated");
|
||||
const { url, options } = h.calls[0];
|
||||
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/api/sales-common/gen-ai/v1/summarizations/generate");
|
||||
assert.equal(options.method, "POST");
|
||||
assert.equal(options.headers.Authorization, "Bearer test-sales-common-secret");
|
||||
assert.equal(options.headers["Content-Type"], "application/json");
|
||||
assert.deepEqual(JSON.parse(options.body), {
|
||||
path: "/api/sales-common/gen-ai/v1/summarizations/generate",
|
||||
promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: "A1"
|
||||
});
|
||||
const loadingRow = h.createProductsDetailRow({ OptyNumber: "A1" });
|
||||
const panel = loadingRow.children[0].children[0];
|
||||
assert.equal(panel.children[1].attributes["aria-busy"], "true");
|
||||
assert.equal(panel.children[2].tag, "table", "products remain below the summary");
|
||||
assert.equal(panel.children[2].children[1].children.length, 1);
|
||||
|
||||
h.createProductsCountButton("A1", products).click();
|
||||
assert.equal(h.calls.length, 1, "collapsing does not request another summary");
|
||||
assert.equal(h.expandedProductOptyNumbers.has("A1"), false);
|
||||
const second = h.requestGenerateAiSummary("A2");
|
||||
await flush();
|
||||
const visible = h.createAiSummaryAlert("A2");
|
||||
h.show([visible]);
|
||||
h.calls[1].resolve(json({ summaryText: "Second opportunity" }));
|
||||
await second;
|
||||
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
|
||||
const unsafeText = "<img src=x onerror=alert(1)>\nLong summary";
|
||||
h.calls[0].resolve(json({ summaryText: unsafeText, lastUpdateDate: "not displayed" }));
|
||||
await first;
|
||||
assert.equal(h.expandedProductOptyNumbers.has("A1"), false, "late responses do not reopen rows");
|
||||
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
|
||||
const ready = h.createAiSummaryAlert("A1");
|
||||
assert.equal(ready.children[0].textContent, "Summary generated by AI");
|
||||
assert.equal(ready.children[1].textContent, unsafeText);
|
||||
assert.equal(ready.children[1].children.length, 0, "AI text is not parsed as HTML");
|
||||
assert.equal(ready.children.length, 2);
|
||||
assert.equal(h.requestLog[0].name, "requestGenerateAiSummary");
|
||||
assert.equal(h.requestLog[0].httpStatus, 200);
|
||||
assert.equal(h.requestLog[0].status, "success");
|
||||
assert.ok(h.requestLog[0].responseBody.includes("summaryText"));
|
||||
assert.ok(!h.formatHeaderBlock(h.requestLog[0].requestHeaders).includes("test-sales-common-secret"));
|
||||
|
||||
h.createProductsCountButton("A1", products).click();
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 3, "expanding again generates a fresh summary");
|
||||
h.createProductsCountButton("A1", products).click();
|
||||
h.createProductsCountButton("A1", products).click();
|
||||
assert.equal(h.calls.length, 3, "rapid collapse/expand reuses the pending request");
|
||||
h.calls[2].resolve(json({ summaryText: "Fresh summary" }));
|
||||
await h.aiSummariesByOptyNumber.get("A1").promise;
|
||||
|
||||
for (const response of [json({}, 500), new Response("<html>login</html>"), json({}), json({ summaryText: " " }), json({ summaryText: 123 })]) {
|
||||
const failure = h.requestGenerateAiSummary("error");
|
||||
await flush();
|
||||
h.calls.at(-1).resolve(response);
|
||||
await failure;
|
||||
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "error");
|
||||
assert.equal(h.requestLog.at(-1).status, "failed");
|
||||
assert.ok(h.requestLog.at(-1).httpStatus);
|
||||
assert.ok(h.createAiSummaryAlert("error").classes.has("opportunities-extension-ai-summary-error"));
|
||||
}
|
||||
const networkFailure = h.requestGenerateAiSummary("error");
|
||||
await flush();
|
||||
h.calls.at(-1).reject(new Error("Network unavailable"));
|
||||
await networkFailure;
|
||||
assert.equal(h.requestLog.at(-1).error, "Network unavailable");
|
||||
const retry = h.requestGenerateAiSummary("error");
|
||||
await flush();
|
||||
h.calls.at(-1).resolve(json({ summaryText: "Recovered" }));
|
||||
await retry;
|
||||
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "ready");
|
||||
|
||||
const scoped = setup(false);
|
||||
const generation = scoped.requestGenerateAiSummary("SC1");
|
||||
await flush();
|
||||
assert.equal(scoped.calls.length, 1, "summary waits for its scoped token");
|
||||
assert.equal(scoped.calls[0].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F");
|
||||
assert.equal(scoped.calls[0].options.method, "GET");
|
||||
assert.equal(scoped.calls[0].options.credentials, "include");
|
||||
assert.equal(scoped.calls[0].options.headers["x-xsrf-token"], "test-xsrf-cookie");
|
||||
scoped.calls[0].resolve(json({ access_token: "scoped-token", expires_in: 3600 }));
|
||||
await flush();
|
||||
assert.equal(scoped.calls[1].options.headers.Authorization, "Bearer scoped-token");
|
||||
scoped.calls[1].resolve(json({ summaryText: "Scoped summary" }));
|
||||
await generation;
|
||||
assert.equal(scoped.requestLog[0].name, "requestTokenRelaySalesCommon");
|
||||
assert.equal(scoped.requestLog[0].httpStatus, 200);
|
||||
assert.equal(scoped.getAccessToken(), "test-bearer-secret", "scoped token does not replace the general token");
|
||||
const normal = scoped.requestTokenRelay();
|
||||
await flush();
|
||||
assert.equal(scoped.calls[2].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay");
|
||||
assert.equal(scoped.calls[2].options.headers["x-xsrf-token"], scoped.calls[0].options.headers["x-xsrf-token"]);
|
||||
scoped.calls[2].resolve(json({ access_token: "normal-token" }));
|
||||
await normal;
|
||||
assert.equal(scoped.requestLog[2].name, "requestTokenRelay");
|
||||
assert.equal(await scoped.requestTokenRelaySalesCommon(), "scoped-token");
|
||||
assert.equal(scoped.calls.length, 3, "valid token is reused");
|
||||
scoped.advance(3600 * 1000);
|
||||
const failedScope = scoped.requestGenerateAiSummary("SC2");
|
||||
await flush();
|
||||
scoped.calls[3].resolve(json({ error: "Unavailable" }, 500));
|
||||
await failedScope;
|
||||
assert.equal(scoped.calls.length, 4, "no summary POST after scoped authentication failure");
|
||||
assert.equal(scoped.aiSummariesByOptyNumber.get("SC2").status, "error");
|
||||
assert.equal(scoped.requestLog[3].status, "failed");
|
||||
|
||||
const pendingToken = scoped.requestTokenRelaySalesCommon();
|
||||
assert.equal(scoped.requestTokenRelaySalesCommon(), pendingToken, "concurrent refresh is deduplicated");
|
||||
await flush();
|
||||
scoped.calls[4].resolve(json({ access_token: "renewed-token", expires_in: 3600 }));
|
||||
await pendingToken;
|
||||
assert.equal(await scoped.requestTokenRelaySalesCommon(), "renewed-token");
|
||||
assert.equal(scoped.calls.length, 5, "failed refresh can be retried and then cached");
|
||||
|
||||
const rejected = scoped.requestGenerateAiSummary("REJECTED");
|
||||
const concurrent = scoped.requestGenerateAiSummary("CONCURRENT");
|
||||
await flush();
|
||||
assert.equal(scoped.calls[5].options.headers.Authorization, "Bearer renewed-token");
|
||||
scoped.calls[5].resolve(json({}, 401));
|
||||
await flush();
|
||||
assert.equal(scoped.calls[7].options.method, "GET");
|
||||
scoped.calls[7].resolve(json({ access_token: "replacement-token", expires_in: 3600 }));
|
||||
await flush();
|
||||
assert.equal(scoped.calls[8].options.headers.Authorization, "Bearer replacement-token");
|
||||
scoped.calls[8].resolve(json({ summaryText: "Recovered after refresh" }));
|
||||
await rejected;
|
||||
scoped.calls[6].resolve(json({}, 401));
|
||||
await flush();
|
||||
assert.equal(scoped.calls[9].options.method, "POST", "late rejection must reuse the already refreshed token");
|
||||
assert.equal(scoped.calls[9].options.headers.Authorization, "Bearer replacement-token");
|
||||
scoped.calls[9].resolve(json({ summaryText: "Concurrent recovery" }));
|
||||
await concurrent;
|
||||
assert.equal(scoped.calls.length, 10);
|
||||
|
||||
const forbidden = scoped.requestGenerateAiSummary("FORBIDDEN");
|
||||
await flush();
|
||||
scoped.calls[10].resolve(json({}, 403));
|
||||
await flush();
|
||||
scoped.calls[11].resolve(json({ access_token: "still-forbidden", expires_in: 3600 }));
|
||||
await flush();
|
||||
scoped.calls[12].resolve(json({}, 403));
|
||||
await forbidden;
|
||||
assert.equal(scoped.calls.length, 13, "only one renewal and one summary retry per generation");
|
||||
assert.equal(scoped.aiSummariesByOptyNumber.get("FORBIDDEN").status, "error");
|
||||
assert.equal(scoped.requestLog.at(-1).httpStatus, 403);
|
||||
assert.equal(scoped.getAccessToken(), "test-bearer-secret");
|
||||
console.log("AI summary tests passed.");
|
||||
}
|
||||
|
||||
run().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -41,9 +41,9 @@ test("every source belongs to an explicit runtime composition, with no duplicate
|
||||
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/features/org-chart/model.js", "src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
|
||||
});
|
||||
|
||||
test("Redwood cascade is byte-identical to the characterized original", () => {
|
||||
test("Redwood cascade is unchanged after removing obsolete AI summary styles", () => {
|
||||
const hash = createHash("sha256").update(read("src/content/styles/redwood.css")).digest("hex");
|
||||
assert.equal(hash, "eaeb7e53757d48a3095f9111c5029d2c923d0dcab92af23454ccd14bdcfc81c4");
|
||||
assert.equal(hash, "3a40f3f75cc1481444be20f8fa8705e6679fc18a8f748561ab820545fbac7853");
|
||||
});
|
||||
|
||||
test("fiscal module can run alone without DOM, browser APIs or network", () => {
|
||||
@@ -58,13 +58,9 @@ test("per-frame state and credentials do not leak between application instances"
|
||||
const b = loadApplication("content");
|
||||
for (const key of Object.keys(a).filter(key => key.endsWith("State"))) assert.notEqual(a[key], b[key], key);
|
||||
a.authSessionState.accessToken = "synthetic-general";
|
||||
a.authSalesCommonState.salesCommonToken = { access_token: "synthetic-scoped", expiresAt: 123 };
|
||||
a.productsContentState.opptyProductsByNumber.set("TEST", { items: [] });
|
||||
a.aiSummaryState.aiSummariesByOptyNumber.set("TEST", { summaryText: "Synthetic" });
|
||||
assert.equal(b.authSessionState.accessToken, "");
|
||||
assert.equal(b.authSalesCommonState.salesCommonToken, null);
|
||||
assert.equal(b.productsContentState.opptyProductsByNumber.size, 0);
|
||||
assert.equal(b.aiSummaryState.aiSummariesByOptyNumber.size, 0);
|
||||
});
|
||||
|
||||
test("blocked localStorage access remains lazy and handled by preference fallbacks", () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function checkIcon(badge, kind, value) {
|
||||
|
||||
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
|
||||
const app = setup();
|
||||
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
|
||||
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123", WorkloadName_c: "Synthetic workload" })) };
|
||||
const count = app.productsDetails.createProductsCountButton("TEST", products);
|
||||
checkIcon(count, "products", "3");
|
||||
assert.equal(count.title, "3 products");
|
||||
@@ -58,7 +58,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
|
||||
const ramp = app.productsDetails.createProductsRampBadge(products);
|
||||
checkIcon(ramp, "ramp", "1 | 2 | 3");
|
||||
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
||||
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
||||
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null, WorkloadName_c: "Synthetic workload" }] });
|
||||
checkIcon(missingRamp, "ramp", "N/A");
|
||||
assert.equal(missingRamp.attributes["data-missing"], "true");
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ test("preparation closes immediately on the second cookie event even while getAl
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test("explicit preparation opens SeaaS initiation and observes cookies without any API requests", async () => {
|
||||
test("explicit preparation opens resource requests Dashboard and observes cookies without any API requests", async () => {
|
||||
let calls = 0;
|
||||
let reads = 0;
|
||||
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||
@@ -97,7 +97,7 @@ test("explicit preparation opens SeaaS initiation and observes cookies without a
|
||||
assert.deepEqual(result.debugRequests, []);
|
||||
assert.ok(!JSON.stringify(result).includes("secret"));
|
||||
assert.equal(h.tabs[0].active, false);
|
||||
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
|
||||
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests");
|
||||
assert.deepEqual(h.removedTabs, [91]);
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ for (const family of ["chrome", "firefox"]) {
|
||||
assert.equal(h.tabs.length, 1);
|
||||
assert.equal(h.tabs[0].active, false);
|
||||
assert.equal(h.tabs[0].windowId, 3);
|
||||
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
|
||||
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests");
|
||||
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
||||
assert.deepEqual(h.removedTabs, [91]);
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ for (const family of ["chrome", "firefox"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("expired SPA session recovery handles 401, 403 and network errors, then releases its in-flight request", async () => {
|
||||
test("expired SPA session recovery handles 401, 403 and network errors, then reuses its successful identity", async () => {
|
||||
let attempts = 0;
|
||||
const app = loadBackground({ fetch: async () => {
|
||||
attempts++;
|
||||
@@ -111,7 +111,8 @@ test("expired SPA session recovery handles 401, 403 and network errors, then rel
|
||||
assert.deepEqual(first.debugRequests.map(entry => entry.httpStatus), [401, 403, null, 200]);
|
||||
assert.deepEqual(app.removedTabs, [91]);
|
||||
assert.equal((await app.dispatch({ type })).ok, true);
|
||||
assert.equal(attempts, 5, "background must not persist identity after the shared request settles");
|
||||
assert.equal(attempts, 4, "successful identity must not be fetched again");
|
||||
assert.equal((await app.dispatch({ type })).debugRequests.length, 0);
|
||||
assert.equal(app.tabs.length, 1);
|
||||
});
|
||||
|
||||
@@ -273,3 +274,25 @@ for (const family of ["chrome", "firefox"]) {
|
||||
assert.equal(attempts, payloads.length);
|
||||
});
|
||||
}
|
||||
|
||||
test("identity cache survives background recreation, shares windows and isolates containers", async () => {
|
||||
const factory = new (require("node:vm").Script)(require("node:fs").readFileSync("src/services/spa/current-user.js", "utf8") + "\ncreateSpaCurrentUser").runInNewContext();
|
||||
const data = {};
|
||||
const sessionStorage = { async get(key) { return { [key]: data[key] }; }, async set(values) { Object.assign(data, values); } };
|
||||
let calls = 0;
|
||||
const deps = { sessionStorage, fetchJson: async () => { calls++; return validIdentity; } };
|
||||
const service = factory(deps);
|
||||
const sender = (windowId, cookieStoreId = "", incognito = false) => ({ tab: { windowId, cookieStoreId, incognito } });
|
||||
await Promise.all([service.requestCurrentUser(sender(1)), service.requestCurrentUser(sender(2))]);
|
||||
assert.equal(calls, 1);
|
||||
await service.requestCurrentUser(sender(3));
|
||||
assert.equal(calls, 1);
|
||||
const restored = await factory(deps).requestCurrentUser(sender(4));
|
||||
assert.equal(restored.user.id, validIdentity.user.id);
|
||||
assert.equal(restored.debugRequests.length, 0);
|
||||
assert.equal(calls, 1);
|
||||
await service.requestCurrentUser(sender(1, "container-2"));
|
||||
await service.requestCurrentUser(sender(1, "", true));
|
||||
assert.equal(calls, 3);
|
||||
assert.ok(Object.values(data).every(user => !user.token));
|
||||
});
|
||||
|
||||
121
tests/debugStorage.test.cjs
Normal file
121
tests/debugStorage.test.cjs
Normal file
@@ -0,0 +1,121 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const { createDebugDom } = require("./support/debug-dom.cjs");
|
||||
const factory = new vm.Script(fs.readFileSync("src/features/debug/state.js", "utf8") + "\ncreateDebugState").runInNewContext();
|
||||
function storage() {
|
||||
const data = new Map();
|
||||
return { data, get length() { return data.size; }, key: i => [...data.keys()][i],
|
||||
getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value), removeItem: key => data.delete(key) };
|
||||
}
|
||||
|
||||
test("opportunities clears debug only when sending the first page, preserving pagination", async () => {
|
||||
const localStorage = storage();
|
||||
const app = loadApplication("content", {
|
||||
localStorage,
|
||||
fetch: async (_url, options) => {
|
||||
if (options.method === "GET") return new Response(JSON.stringify({ items: [{ isDefault: true, savedSearchNumber: "test" }] }));
|
||||
const offset = JSON.parse(options.body).offset;
|
||||
return new Response(JSON.stringify({ items: [{ OptyNumber: String(offset) }], offset, count: 1, hasMore: offset === 0 }));
|
||||
}
|
||||
});
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.debugContentLog.logRequestStart({ name: "old", method: "GET" });
|
||||
const range = { startCloseDate: "2026-09-01", endCloseDate: "2026-12-01" };
|
||||
const query = () => app.fusionOpportunities.requestOpportunitiesPage("test", "Current Quarter", range, 0, [], 0, 1);
|
||||
await query();
|
||||
assert.equal(app.debugState.generation, 1);
|
||||
assert.deepEqual(Array.from(app.debugState.requestLog, entry => entry.name), ["requestOpportunities", "requestOpportunities"]);
|
||||
await query();
|
||||
assert.equal(app.debugState.generation, 2);
|
||||
assert.equal(app.debugState.requestLog.length, 2);
|
||||
app.debugState.flush();
|
||||
assert.equal([...localStorage.data.values()].some(value => value.includes('"name":"old"')), false);
|
||||
});
|
||||
|
||||
test("browser timer receiver does not prevent Token Relay from starting or completing", async () => {
|
||||
let calls = 0;
|
||||
const app = loadApplication("content", {
|
||||
localStorage: storage(), AbortController,
|
||||
setTimeout: function(callback, delay) {
|
||||
"use strict";
|
||||
assert.equal(this, undefined);
|
||||
return setTimeout(callback, delay);
|
||||
},
|
||||
clearTimeout: function(id) { "use strict"; assert.equal(this, undefined); clearTimeout(id); },
|
||||
fetch: async () => { calls++; return new Response(JSON.stringify({ access_token: "synthetic" })); }
|
||||
});
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
|
||||
assert.equal(await app.authTokenRelay.requestTokenRelay(), "synthetic");
|
||||
app.debugState.flush();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(app.debugState.requestLog[0].status, "success");
|
||||
});
|
||||
|
||||
test("Token Relay times out and records failure when fetch never completes", async () => {
|
||||
let signal;
|
||||
const app = loadApplication("content", {
|
||||
AbortController,
|
||||
setTimeout: (callback, delay) => setTimeout(callback, delay === 30000 ? 5 : delay),
|
||||
clearTimeout,
|
||||
fetch: (_url, options) => { signal = options.signal; return new Promise(() => {}); }
|
||||
});
|
||||
app.debugContentView.renderDebugPanel = () => {};
|
||||
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
|
||||
await assert.rejects(app.authTokenRelay.requestTokenRelay(), /timed out/);
|
||||
app.debugState.flush();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(app.debugState.requestLog[0].status, "failed");
|
||||
});
|
||||
test("debug stores individual complete records, reloads Dates and preserves pending request handles", () => {
|
||||
const localStorage = storage();
|
||||
const state = factory({ localStorage, crypto: { randomUUID: () => "tab-a" } });
|
||||
state.reset();
|
||||
const entry = { id: 1, startedAt: new Date(), completedAt: null, responseBody: "x".repeat(100000), requestHeaders: { Authorization: "synthetic" } };
|
||||
state.requestLog.push(entry); state.persist(entry);
|
||||
assert.equal(JSON.parse(localStorage.getItem("opportunities-extension-debug:tab-a:1")).responseBody.length, 100000);
|
||||
entry.responseBody = "changed in memory";
|
||||
state.reload();
|
||||
assert.equal(state.requestLog[0], entry);
|
||||
assert.equal(entry.responseBody.length, 100000);
|
||||
assert.equal(typeof entry.startedAt.getTime, "function");
|
||||
});
|
||||
test("reset clears only this tab's debug records and ignores old request completions", () => {
|
||||
const localStorage = storage();
|
||||
localStorage.setItem("preferences", "preserved");
|
||||
localStorage.setItem("opportunities-extension-debug:other:1", "preserved");
|
||||
const state = factory({ localStorage, crypto: { randomUUID: () => "tab-a" } });
|
||||
const entry = { id: 1 }; state.requestLog.push(entry); state.persist(entry);
|
||||
state.reset(); state.persist(entry);
|
||||
assert.equal(state.requestLog.length, 0);
|
||||
assert.equal(localStorage.getItem("opportunities-extension-debug:tab-a:1"), null);
|
||||
assert.equal(localStorage.getItem("preferences"), "preserved");
|
||||
assert.equal(localStorage.getItem("opportunities-extension-debug:other:1"), "preserved");
|
||||
});
|
||||
test("quota failure retains complete data in memory without breaking requests", () => {
|
||||
const state = factory({ localStorage: { setItem() { throw new Error("quota"); } } });
|
||||
const entry = { id: 1, responseBody: "complete" }; state.requestLog.push(entry);
|
||||
assert.doesNotThrow(() => state.persist(entry));
|
||||
assert.match(state.storageError, /full/);
|
||||
state.reload(); assert.equal(state.requestLog[0].responseBody, "complete");
|
||||
});
|
||||
test("hidden debug has no request DOM; opening hydrates summaries and expanding mounts details", () => {
|
||||
const dom = createDebugDom(); dom.panel.hidden = true;
|
||||
const app = loadApplication("content", { document: dom.document, localStorage: storage() });
|
||||
const entry = app.debugContentLog.logRequestStart({ name: "requestExample", method: "GET", url: "https://example.invalid" });
|
||||
assert.equal(dom.panel.children.length, 0);
|
||||
app.debugContentView.toggleDebugPanel();
|
||||
const find = cls => dom.descendants(dom.panel).find(node => node.className === cls);
|
||||
assert.equal(find("opportunities-extension-debug-details"), undefined);
|
||||
const row = dom.descendants(dom.panel).find(node => node.tag === "details");
|
||||
row.open = true; row.listeners.toggle();
|
||||
assert.ok(find("opportunities-extension-debug-details"));
|
||||
app.debugContentView.closeDebugPanel();
|
||||
assert.equal(dom.panel.children.length, 0);
|
||||
app.debugState.reset();
|
||||
app.debugContentLog.logRequestFailure(entry, "Late failure");
|
||||
assert.equal(app.debugState.requestLog.length, 0);
|
||||
});
|
||||
@@ -42,6 +42,7 @@ test("catalog and identity secrets are revealed exactly by the eye button and hi
|
||||
name: "requestCurrentUser", method: "GET", startedAt: new Date().toISOString(), status: "success",
|
||||
responseBody: JSON.stringify({ user: { roles: ["SIMPLE_USER"] }, token: "identity-exact-secret" })
|
||||
}]);
|
||||
h.nodes().filter(node => node.tag === "details").forEach(node => { node.open = true; node.listeners.toggle(); });
|
||||
const text = node => node.text + node.children.map(text).join("");
|
||||
const toggle = () => h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility").listeners.click();
|
||||
assert.doesNotMatch(text(h.panel), /exact-secret/);
|
||||
@@ -99,9 +100,9 @@ test("complete large responses stay formatted, masked and colored in both runtim
|
||||
test("request categories cover every current family and unknown requests stay visible", () => {
|
||||
const h = fixture();
|
||||
for (const [name, category] of Object.entries({
|
||||
requestTokenRelay: "authentication", requestTokenRelaySalesCommon: "authentication",
|
||||
requestTokenRelay: "authentication",
|
||||
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
|
||||
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpportunitiesByContact: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
||||
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpportunitiesByContact: "opportunities", requestOpptyProducts: "products",
|
||||
requestQueryAccountContact: "contacts", requestPersistContact: "contacts",
|
||||
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
||||
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
|
||||
@@ -233,6 +234,7 @@ test("colored JSON preserves exact masked text, safe strings, eye toggle and HTT
|
||||
name: "requestTokenRelay", method: "POST", startedAt: new Date().toISOString(),
|
||||
httpStatus: 401, status: "failed", responseBody: response, requestHeaders: { Authorization: "synthetic-private" }
|
||||
}]);
|
||||
h.nodes().filter(node => node.tag === "details").forEach(node => { node.open = true; node.listeners.toggle(); });
|
||||
const text = node => node.text + node.children.map(text).join("");
|
||||
const responseBlock = () => h.nodes().find(node => node.tag === "section" && node.children[0]?.textContent === "Response Body").children[1];
|
||||
assert.equal(text(responseBlock()), h.app.debugContentLog.formatDebugBody(response));
|
||||
@@ -308,7 +310,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
|
||||
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||
const root = path.join(__dirname, "..");
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css")
|
||||
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||
|
||||
@@ -120,7 +120,7 @@ test("installBase sorts typed values and restores the selected header across app
|
||||
const second = setup(storage);
|
||||
second.app.installBaseView.openInstallBaseModal(customer).render(items);
|
||||
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
|
||||
assert.equal(rows[0].children[7].textContent, "2");
|
||||
assert.equal(rows[0].children[6].textContent, "2");
|
||||
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "quantity").parent.attributes["aria-sort"], "ascending");
|
||||
assert.equal(items[0].quantity, 10);
|
||||
});
|
||||
@@ -136,13 +136,13 @@ test("installed base searches every displayed column and formatted values, inclu
|
||||
currentSupport: 1234.56, orderNumber: "ORD-321", supportStatus: "ACTIVE", quantityType: "Processor",
|
||||
quantity: 7, supportStartDate: "2026-03-12", supportEndDate: "2027-04-13", licenseValue: 9876.54, shipDate: "2020-05-14",
|
||||
hiddenField: "secretword" };
|
||||
input.value = "sao beta";
|
||||
input.value = "group beta";
|
||||
input.listeners.input();
|
||||
assert.equal(modal.attributes["aria-busy"], "true");
|
||||
view.render([row, { productName: "Other product" }]);
|
||||
const rows = () => walk(modal).filter(node => node.tag === "td").length / 12;
|
||||
const rows = () => walk(modal).filter(node => node.tag === "td").length / 11;
|
||||
assert.equal(rows(), 1);
|
||||
for (const query of [...Object.values(row).slice(0, 12), "1,234.56", "9,876.54", "12/03/2026", "13/04/2027", "14/05/2020"]) {
|
||||
for (const query of [...Object.values(row).slice(1, 12), "1,234.56", "9,876.54", "12/03/2026", "13/04/2027", "14/05/2020"]) {
|
||||
input.value = String(query); input.listeners.input();
|
||||
assert.equal(rows(), 1, String(query));
|
||||
assert.equal(walk(modal).find(node => node.tag === "input"), input, "input is not replaced while typing");
|
||||
@@ -160,7 +160,7 @@ test("installed base searches every displayed column and formatted values, inclu
|
||||
assert.ok(walk(modal).some(node => node.textContent === "Synthetic failure"));
|
||||
app.installBaseView.closeInstallBaseModal();
|
||||
app.installBaseView.openInstallBaseModal(customer).render([row]);
|
||||
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 12, "reopening starts without an invisible filter");
|
||||
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 11, "reopening starts without an invisible filter");
|
||||
});
|
||||
|
||||
test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => {
|
||||
@@ -168,7 +168,7 @@ test("modal shows skeleton, renders only requested fields safely and ignores clo
|
||||
const first = app.installBaseView.openInstallBaseModal(customer);
|
||||
const modal = overlay.children[0];
|
||||
assert.equal(modal.attributes["aria-busy"], "true");
|
||||
assert.equal(walk(modal).filter(node => node.tag === "th").length, 12);
|
||||
assert.equal(walk(modal).filter(node => node.tag === "th").length, 11);
|
||||
const second = app.installBaseView.openInstallBaseModal(customer);
|
||||
first.render([{ productName: "Stale product" }]);
|
||||
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
|
||||
@@ -176,14 +176,14 @@ test("modal shows skeleton, renders only requested fields safely and ignores clo
|
||||
currentSupport: 1234.56, licenseValue: 0, supportStartDate: "2026-03-12", secret: "not-visible" }]);
|
||||
const current = overlay.children[0];
|
||||
const cells = walk(current).filter(node => node.tag === "td");
|
||||
assert.equal(cells.length, 12);
|
||||
assert.equal(cells[2].textContent, "<script>unsafe</script>");
|
||||
assert.equal(cells[2].children.length, 0);
|
||||
assert.equal(cells[3].textContent, "1,234.56");
|
||||
assert.equal(cells[5].children[0].dataset.status, "ACTIVE");
|
||||
assert.equal(cells[7].textContent, "0");
|
||||
assert.equal(cells[10].textContent, "0.00");
|
||||
assert.ok(cells[8].textContent.includes("2026"));
|
||||
assert.equal(cells.length, 11);
|
||||
assert.equal(cells[1].textContent, "<script>unsafe</script>");
|
||||
assert.equal(cells[1].children.length, 0);
|
||||
assert.equal(cells[2].textContent, "1,234.56");
|
||||
assert.equal(cells[4].children[0].dataset.status, "ACTIVE");
|
||||
assert.equal(cells[6].textContent, "0");
|
||||
assert.equal(cells[9].textContent, "0.00");
|
||||
assert.ok(cells[7].textContent.includes("2026"));
|
||||
current.listeners.keydown({ key: "Tab", preventDefault() {} });
|
||||
assert.equal(document.activeElement.type, "search");
|
||||
current.listeners.keydown({ key: "Tab", preventDefault() {} });
|
||||
|
||||
@@ -25,9 +25,10 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
||||
const host = new Element("host");
|
||||
document = { createElement: tag => new Element(tag), getElementById: () => host };
|
||||
const state = { status: "ready", items };
|
||||
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
|
||||
const h = { state, filtered: items, version: 1, range: { startCloseDate: "2026-09-01", endCloseDate: "2027-03-01" }, instances: [], loads: 0, host, document };
|
||||
class Calendar {
|
||||
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
|
||||
setOption(key, value) { this.options[key] = value; }
|
||||
render() { this.rendered = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
updateSize() { this.resized = true; }
|
||||
@@ -37,7 +38,7 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
||||
addEventSource(events) { this.events = events; }
|
||||
}
|
||||
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
|
||||
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
|
||||
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version, getRange: () => h.range,
|
||||
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
|
||||
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
|
||||
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
|
||||
@@ -57,10 +58,6 @@ test("calendar controls, accessibility labels and missing dates are English", ()
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
|
||||
const { options } = h.activate();
|
||||
assert.equal(options.locale, "en");
|
||||
assert.deepEqual(plain(options.buttonText), { today: "Today", month: "Month", week: "Week" });
|
||||
assert.deepEqual(plain(options.buttonHints), { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" });
|
||||
assert.equal(options.moreLinkHint(1), "View 1 more opportunity");
|
||||
assert.equal(options.moreLinkHint(3), "View 3 more opportunities");
|
||||
assert.ok(h.nodes().some(node => node.attributes["aria-label"] === "Opportunity views"));
|
||||
assert.ok(h.nodes().some(node => node.textContent === "No valid Close Date (1)"));
|
||||
});
|
||||
@@ -126,7 +123,7 @@ test("initial calendar date selects the next close date, last past date or today
|
||||
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
|
||||
});
|
||||
|
||||
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
|
||||
test("overview is the default; tabs lazily initialize and reuse a scrollable monthly calendar", () => {
|
||||
const h = setup(); h.mount();
|
||||
const surface = h.byClass("opportunities-extension-calendar");
|
||||
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
|
||||
@@ -137,13 +134,13 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
|
||||
tabs[1].click();
|
||||
const instance = h.instances[0];
|
||||
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
|
||||
assert.equal(instance.options.initialView, "dayGridMonth");
|
||||
assert.equal(instance.options.initialView, "referenceMonths");
|
||||
assert.equal(instance.options.height, "100%");
|
||||
assert.equal(instance.options.expandRows, true);
|
||||
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
|
||||
|
||||
assert.equal(instance.options.editable, false);
|
||||
assert.equal(instance.options.dayMaxEvents, 2);
|
||||
assert.equal(instance.options.moreLinkText(7), "... +7");
|
||||
assert.equal(instance.options.dayMaxEvents, false);
|
||||
assert.equal(instance.options.headerToolbar, false);
|
||||
assert.deepEqual(plain(instance.options.validRange), {start:"2026-09-01",end:"2027-03-01"});
|
||||
assert.equal(instance.options.weekNumbers, true);
|
||||
tabs[0].click(); tabs[1].click();
|
||||
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
|
||||
@@ -151,24 +148,15 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
|
||||
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
|
||||
});
|
||||
|
||||
test("responsive event limit reserves room for every week and the overflow control", () => {
|
||||
const { api } = setup();
|
||||
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
|
||||
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
|
||||
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
|
||||
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
|
||||
});
|
||||
|
||||
test("calendar follows filtered data; only a new Reference request changes its date", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
|
||||
const calendar = h.activate();
|
||||
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
|
||||
assert.equal(calendar.dates.length, 0);
|
||||
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
|
||||
h.range = { startCloseDate: "2026-12-01", endCloseDate: "2027-03-01" }; h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
|
||||
h.api.render(h.filtered, h.state);
|
||||
assert.deepEqual(calendar.dates, ["2027-02-20"]);
|
||||
assert.deepEqual(calendar.dates, ["2026-12-01"]);
|
||||
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
|
||||
h.filtered = []; h.api.render(h.filtered, h.state);
|
||||
assert.equal(calendar.events.length, 0);
|
||||
@@ -194,7 +182,7 @@ test("event details close on changed data, loading/error hide stale calendar con
|
||||
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.render([], h.state);
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
for (const status of ["loading", "error", "empty"]) {
|
||||
for (const status of ["loading", "error"]) {
|
||||
h.api.render([], { status, items: [], message: "State message" });
|
||||
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
|
||||
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
|
||||
@@ -202,29 +190,6 @@ test("event details close on changed data, loading/error hide stale calendar con
|
||||
}
|
||||
});
|
||||
|
||||
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
|
||||
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
|
||||
const calendar = h.activate();
|
||||
const trigger = h.document.createElement("a");
|
||||
const allSegs = calendar.events.map(event => ({ event }));
|
||||
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
|
||||
jsEvent: { currentTarget: trigger, preventDefault() {} } });
|
||||
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
|
||||
const list = h.byClass("opportunities-calendar-day-list");
|
||||
assert.equal(list.children.length, 3);
|
||||
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
|
||||
list.children[2].click();
|
||||
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
|
||||
h.api.closeDetails();
|
||||
assert.equal(h.document.activeElement, trigger);
|
||||
open(); h.api.selectView("overview");
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
h.api.selectView("calendar"); open();
|
||||
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
|
||||
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
|
||||
});
|
||||
|
||||
test("calendar events show their stage and handle missing stage values", () => {
|
||||
const h = setup();
|
||||
const calendar = h.activate();
|
||||
@@ -237,3 +202,13 @@ test("calendar events show their stage and handle missing stage values", () => {
|
||||
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Reference boundaries exclude the next period and keep empty months visible", () => {
|
||||
const h = setup([opportunity("before", "2026-08-31"), opportunity("first", "2026-09-01"), opportunity("last", "2026-11-30"), opportunity("next", "2026-12-01")]);
|
||||
h.range = {startCloseDate:"2026-09-01",endCloseDate:"2026-12-01"};
|
||||
const calendar = h.activate();
|
||||
assert.deepEqual(plain(calendar.events.map(e=>e.id)), ["first","last"]);
|
||||
h.api.render([], {status:"empty",items:[]});
|
||||
assert.equal(h.byClass("opportunities-calendar-grid").hidden,false);
|
||||
assert.equal(calendar.events.length,0);
|
||||
});
|
||||
|
||||
@@ -37,13 +37,11 @@ function setup(items = [synthetic()], localStorage) {
|
||||
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
|
||||
const app = loadApplication("content", { document, Intl, Date, localStorage });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
const summaries = [];
|
||||
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
|
||||
app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
|
||||
const rows = () => table.children.find(child => child.tag === "tbody").children;
|
||||
const cell = (key, index = 0) => rows()[index].children.find(child => child.attributes["data-column"] === key);
|
||||
const headers = () => table.children.find(child => child.tag === "thead").children[0].children;
|
||||
return { app, table, rows, cell, headers, summaries };
|
||||
return { app, table, rows, cell, headers };
|
||||
}
|
||||
|
||||
test("Overview restores independent column and direction preferences across reloads and new data", () => {
|
||||
@@ -104,7 +102,7 @@ test("ramp months preserve zero and flag missing values beside the product count
|
||||
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
|
||||
]) {
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
||||
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
|
||||
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c, WorkloadName_c: "Synthetic workload" }))
|
||||
});
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
if (expected === null) assert.equal(badge(), undefined);
|
||||
@@ -121,12 +119,11 @@ test("ramp months preserve zero and flag missing values beside the product count
|
||||
}
|
||||
});
|
||||
|
||||
test("products remain expandable with AI summary and a ten-column detail row after sorting", () => {
|
||||
test("products remain expandable with a ten-column detail row after sorting", () => {
|
||||
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
|
||||
h.app.productsContentState.opptyProductsByNumber.set("B", { items: [{ ProdGroupName: "Cloud", RevnAmount: "10" }] });
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
||||
assert.deepEqual(h.summaries, ["B"]);
|
||||
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
|
||||
assert.equal(h.rows()[1].children[0].colSpan, 10);
|
||||
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
||||
@@ -136,7 +133,6 @@ test("products remain expandable with AI summary and a ten-column detail row aft
|
||||
assert.equal(h.rows()[2].children[0].colSpan, 10);
|
||||
findClass(h.cell("name", 1), "opportunities-extension-products-count").click();
|
||||
assert.equal(h.rows().length, 2);
|
||||
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
|
||||
});
|
||||
|
||||
test("product labels support multiple digits without changing their tooltip", () => {
|
||||
@@ -149,7 +145,7 @@ test("product labels support multiple digits without changing their tooltip", ()
|
||||
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
|
||||
});
|
||||
|
||||
test("opportunity projection alert is last and aggregates only divergent products", () => {
|
||||
test("opportunity projection alert follows the ramp and aggregates only divergent products", () => {
|
||||
const h = setup();
|
||||
const alert = () => findClass(h.cell("name"), "opportunities-extension-opportunity-projection-alert");
|
||||
const product = amount => ({ RampMonths_c: 0, workload: { adjustedACR: 1200, consumptionStartDate: "2026-10-01",
|
||||
@@ -159,17 +155,15 @@ test("opportunity projection alert is last and aggregates only divergent product
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
assert.equal(alert().title, "Consumption forecast differs in 2 products. View products.");
|
||||
const actions = findClass(h.cell("name"), "opportunities-extension-cell-actions");
|
||||
assert.equal(actions.children.at(-1), alert());
|
||||
assert.equal(actions.children[2], alert());
|
||||
assert.equal(alert().tag, "button");
|
||||
assert.equal(alert().attributes["aria-expanded"], "false");
|
||||
alert().click();
|
||||
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
|
||||
assert.equal(alert().attributes["aria-expanded"], "true");
|
||||
assert.equal(walk(h.rows()[1]).filter(node => node.attributes["aria-haspopup"] === "dialog").length, 2);
|
||||
assert.deepEqual(h.summaries, ["TEST-1"]);
|
||||
alert().click();
|
||||
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row", "clicking again never collapses the list");
|
||||
assert.deepEqual(h.summaries, ["TEST-1"], "already expanded list is not reloaded");
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(100), {}] });
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
assert.equal(alert(), undefined);
|
||||
@@ -200,7 +194,7 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
|
||||
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3, WorkloadName_c: "Synthetic workload" }] });
|
||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||
const actions = h.cell("name").children[0].children[1];
|
||||
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
|
||||
@@ -319,6 +313,35 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.ok(remaining > 0);
|
||||
assert.ok(remaining / 2 >= 12 + 114, "four customer controls fit after the left inset");
|
||||
}
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css"].map(read).join("\n");
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
test("Booking replaces ramp only when every product has no Workload name", () => {
|
||||
const h = setup();
|
||||
for (const value of [undefined, null, "", " ", "-"]) {
|
||||
const badge = h.app.productsDetails.createProductsRampBadge({items:[{WorkloadName_c:value},{WorkloadName_c:" - "}]}, {});
|
||||
assert.equal(badge.textContent,"Booking");
|
||||
assert.equal(badge.tag,"span");
|
||||
assert.equal(badge.attributes["aria-haspopup"],undefined);
|
||||
}
|
||||
const mixed = h.app.productsDetails.createProductsRampBadge({items:[{WorkloadName_c:"-",RampMonths_c:2},{WorkloadName_c:"Migration",RampMonths_c:3}]},{});
|
||||
assert.equal(mixed.textContent,"2 | 3");
|
||||
assert.equal(mixed.tag,"button");
|
||||
});
|
||||
test("Booking and Workload filters use product names and compose with shared opportunity filtering", () => {
|
||||
const h = setup();
|
||||
const selected = h.app.opportunitiesFilterState.selectedProductTypes;
|
||||
const result = { items: [{ WorkloadName_c: "-" }, { WorkloadName_c: "" }] };
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", result);
|
||||
selected.delete("Workload");
|
||||
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
|
||||
selected.clear(); selected.add("Workload");
|
||||
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 0);
|
||||
result.items[1].WorkloadName_c = "Migration";
|
||||
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
|
||||
selected.clear();
|
||||
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 0);
|
||||
selected.add("Booking"); selected.add("Workload");
|
||||
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
|
||||
});
|
||||
|
||||
@@ -227,5 +227,8 @@ test("product column opens accessible read-only comparison and restores focus/ba
|
||||
assert.equal(content.inert, false);
|
||||
assert.equal(document.activeElement, alert);
|
||||
assert.equal(app.rampProjectionView.createIndicator(product(), {}).textContent, "OK");
|
||||
assert.equal(app.rampProjectionView.createIndicator({}, {}).textContent, "-");
|
||||
assert.equal(app.rampProjectionView.createIndicator({}, {}).disabled, true);
|
||||
app.rampProjectionView.open(product(), {}, alert);
|
||||
assert.ok(walk(host).some(node => node.textContent === "0 months with a difference"));
|
||||
app.rampProjectionView.close();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,42 @@ const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
|
||||
test("failed preparation exposes Retry, deduplicates clicks and removes it on success", async () => {
|
||||
const host = { append() {} };
|
||||
const nodes = [];
|
||||
const app = loadApplication("content", {
|
||||
document: { querySelector: () => host, getElementById: () => null,
|
||||
createElement: tag => {
|
||||
const node = { tag, children: [], setAttribute() {}, append(child) { this.children.push(child); },
|
||||
addEventListener(_event, fn) { this.click = fn; },
|
||||
set textContent(value) { this.text = value; this.children = []; } };
|
||||
nodes.push(node); return node;
|
||||
} },
|
||||
setTimeout: () => 1, clearTimeout() {}
|
||||
});
|
||||
let attempts = 0;
|
||||
app.currentUser.ensure = async () => {};
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.currentUser.getUserId = () => "test@example.invalid";
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||
if (message.type === "opportunitiesExtension.requestResourceUser") return { ok: true };
|
||||
return { ok: ++attempts > 1 };
|
||||
};
|
||||
await app.srSessionPreparation.ensure();
|
||||
const retry = nodes.find(node => node.tag === "button");
|
||||
assert.equal(retry.text, "Retry");
|
||||
retry.click();
|
||||
const pending = app.srSessionPreparation.retry();
|
||||
assert.equal(app.srSessionPreparation.retry(), pending);
|
||||
await pending;
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(nodes[0].text, "SR session ready");
|
||||
assert.equal(nodes[0].children.length, 0);
|
||||
await app.srSessionPreparation.retry();
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test("preparation runs once, shares pending work and reports loading and completion", async () => {
|
||||
const app = loadApplication("content");
|
||||
const progress = [];
|
||||
@@ -26,7 +62,8 @@ test("preparation runs once, shares pending work and reports loading and complet
|
||||
};
|
||||
const first = app.srSessionPreparation.ensure();
|
||||
assert.equal(app.srSessionPreparation.ensure(), first);
|
||||
assert.equal(requests, 0, "identity must load first");
|
||||
assert.equal(requests, 1, "SR session starts while SPA identity is pending");
|
||||
assert.equal(resourceRequests, 0, "resource user still requires the identity");
|
||||
resolveUser();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(progress, [["session", "running"]]);
|
||||
@@ -38,7 +75,7 @@ test("preparation runs once, shares pending work and reports loading and complet
|
||||
assert.deepEqual(progress.at(-1), ["session", "complete"]);
|
||||
});
|
||||
|
||||
test("SALES_REP skips preparation; failed preparation does not auto-repeat or reject modal loading", async () => {
|
||||
test("failed parallel preparation does not auto-repeat or reject modal loading", async () => {
|
||||
for (const allowed of [false, true]) {
|
||||
const app = loadApplication("content");
|
||||
let requests = 0;
|
||||
@@ -50,7 +87,7 @@ test("SALES_REP skips preparation; failed preparation does not auto-repeat or re
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => { requests += 1; return { ok: false }; };
|
||||
await app.srSessionPreparation.ensure();
|
||||
await app.srSessionPreparation.ensure();
|
||||
assert.equal(requests, allowed ? 1 : 0);
|
||||
assert.deepEqual(progress, allowed ? [["session", "running"], ["session", "error"]] : []);
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(progress, [["session", "running"], ["session", "error"]]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null
|
||||
app.platformContentMessages.refreshXsrfCookie = async () => { refreshes.push(true); return { refreshed: true }; };
|
||||
return {
|
||||
...app.opportunitiesFiscalPeriods, ...app.fusionOpportunities, ...app.authTokenRelay,
|
||||
...app.authSalesCommon, ...app.preferencesStorage, ...app.opportunitiesFilters,
|
||||
...app.preferencesStorage, ...app.opportunitiesFilters,
|
||||
...app.opportunitiesFormatters, ...app.contentTheme, ...app.debugState,
|
||||
setVersion: value => { app.opportunitiesQueryState.periodRequestVersion = value; },
|
||||
setTypes: values => { app.preferencesState.selectedOpportunityTypeValues = new Set(values); },
|
||||
|
||||
Reference in New Issue
Block a user