Implemented the feature to identify and update estimates based on the ACR value, consumption start date, and ramp-up
This commit is contained in:
166
dist/chromium/background.js
vendored
166
dist/chromium/background.js
vendored
@@ -175,6 +175,8 @@ function createMessageTypes() {
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestWorkloads: "opportunitiesExtension.requestWorkloads",
|
||||
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
|
||||
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||
@@ -923,6 +925,140 @@ function createSpaWorkloadActions({ fetchJson }) {
|
||||
return { requestWlActions };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/workloads.js
|
||||
function createSpaWorkloads({ fetchJson, getCachedWorkloads, normalizeCacheMaxAge, invalidateWorkloadsCache }) {
|
||||
async function requestWorkloadForecastUpdate(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const id = String(message.workloadId ?? "");
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(id)) throw new Error("A numeric workload ID is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
if (!Array.isArray(message.payload) || !message.payload.length) throw new Error("Monthly estimates are required.");
|
||||
const periods = new Set();
|
||||
const payload = message.payload.map(({ year, month, amount, updatedBy }) => {
|
||||
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12
|
||||
|| typeof amount !== "number" || !Number.isFinite(amount) || amount < 0
|
||||
|| typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("Invalid monthly estimate.");
|
||||
const period = `${year}-${month}`;
|
||||
if (periods.has(period)) throw new Error("Duplicate forecast month.");
|
||||
periods.add(period);
|
||||
return { year, month, amount, updatedBy: updatedBy.trim() };
|
||||
});
|
||||
const result = await fetchJson(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads/${id}/forecast`, {
|
||||
method: "PATCH", credentials: "include",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", "wwb-provider-authorization": token.trim() },
|
||||
body: JSON.stringify(payload)
|
||||
}, "requestWorkloadForecastUpdate", debugRequests, { allowEmpty: true });
|
||||
await invalidateWorkloadsCache().catch(() => {});
|
||||
return { ok: true, result, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestWorkloads(message) {
|
||||
const customerId = String(message.customerId ?? "").trim();
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(customerId)) throw new Error("A numeric CustomerAccount.PartyId is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
const result = await getCachedWorkloads(customerId, token.trim(), normalizeCacheMaxAge(message.cacheMaxAgeMs), message.principal);
|
||||
return { ok: true, customerId, ...result };
|
||||
}
|
||||
|
||||
async function fetchAllWorkloads(customerId, token) {
|
||||
const debugRequests = [];
|
||||
const workloads = new Map();
|
||||
let offset = 0;
|
||||
let limit = 500;
|
||||
try {
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = new URL(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers/${customerId}/workloads`);
|
||||
for (const [name, value] of Object.entries({ workloadStatus: "Pipeline,Implementation,Live", opportunityForecastType: "All",
|
||||
includeForEstimates: "ALL", financialYear: "ROLLING_4_QTRS", offset, limit })) url.searchParams.set(name, String(value));
|
||||
const result = await fetchJson(url.toString(), {
|
||||
method: "GET", credentials: "include",
|
||||
headers: { Accept: "application/json", "wwb-provider-authorization": token }
|
||||
}, "requestWorkloads", debugRequests);
|
||||
if (!result || !Array.isArray(result.workloads) || typeof result.hasNext !== "boolean"
|
||||
|| result.workloads.some(item => !item || !/^\d+$/.test(String(item.id ?? "")))) {
|
||||
throw new Error("requestWorkloads returned an invalid collection.");
|
||||
}
|
||||
const countBefore = workloads.size;
|
||||
for (const item of result.workloads) workloads.set(String(item.id), item);
|
||||
if (!result.hasNext) return { workloads: [...workloads.values()], debugRequests };
|
||||
const responseOffset = Number(result.offset ?? offset);
|
||||
const responseLimit = Number(result.limit ?? limit);
|
||||
if (workloads.size === countBefore || !Number.isSafeInteger(responseOffset) || responseOffset !== offset
|
||||
|| !Number.isSafeInteger(responseLimit) || responseLimit <= 0 || !Number.isSafeInteger(responseOffset + responseLimit)) {
|
||||
throw new Error("requestWorkloads pagination did not advance.");
|
||||
}
|
||||
offset = responseOffset + responseLimit;
|
||||
limit = responseLimit;
|
||||
}
|
||||
throw new Error("requestWorkloads exceeded the pagination limit.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestWorkloads, fetchAllWorkloads, requestWorkloadForecastUpdate };
|
||||
}
|
||||
|
||||
// Source: src/features/products/workloads-cache.js
|
||||
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
|
||||
const KEY = "opportunitiesExtension.workloadsCache.v1";
|
||||
let cachePromise = null;
|
||||
let writePromise = Promise.resolve();
|
||||
let generation = 0;
|
||||
const inFlight = new Map();
|
||||
|
||||
function read() {
|
||||
if (!cachePromise) cachePromise = storageLocalGet(KEY)
|
||||
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
|
||||
.catch(() => ({}));
|
||||
return cachePromise;
|
||||
}
|
||||
function write(cache) {
|
||||
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
|
||||
return writePromise;
|
||||
}
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
inFlight.clear();
|
||||
cachePromise = Promise.resolve({});
|
||||
await write({});
|
||||
}
|
||||
async function get(customerId, token, maxAgeMs, principal) {
|
||||
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
|
||||
// isMyWorkload is user-specific. Never persist a result without a known principal.
|
||||
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
|
||||
const key = JSON.stringify([scope, customerId]);
|
||||
const currentGeneration = generation;
|
||||
const cache = await read();
|
||||
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
|
||||
const entry = cache[key];
|
||||
const age = Date.now() - Number(entry?.fetchedAt);
|
||||
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
|
||||
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
|
||||
}
|
||||
if (!inFlight.has(key)) {
|
||||
const flight = fetchAllWorkloads(customerId, token).then(async result => {
|
||||
const cachedAt = Date.now();
|
||||
if (generation === currentGeneration) {
|
||||
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
|
||||
await write(cache).catch(() => {});
|
||||
}
|
||||
return { ...result, cachedAt, fromCache: false };
|
||||
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
|
||||
inFlight.set(key, flight);
|
||||
}
|
||||
return inFlight.get(key);
|
||||
}
|
||||
return { get, invalidate };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/action-options.js
|
||||
function createSpaActionOptions({ fetchJson }) {
|
||||
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||
@@ -2520,6 +2656,9 @@ function registerBackgroundMessages({
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestWorkloads,
|
||||
requestWorkloadForecastUpdate,
|
||||
invalidateWorkloadsCache,
|
||||
requestActionTeamMembers,
|
||||
requestActionFindLookup,
|
||||
requestMgmtAction,
|
||||
@@ -2597,7 +2736,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
@@ -2686,6 +2825,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
|
||||
request(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestWlActions) {
|
||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workload actions.",
|
||||
@@ -2876,6 +3024,17 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const workloadsCache = createWorkloadsCache({
|
||||
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
|
||||
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||
});
|
||||
const spaWorkloads = createSpaWorkloads({
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
getCachedWorkloads: (...args) => workloadsCache.get(...args),
|
||||
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
|
||||
});
|
||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||
@@ -2907,6 +3066,9 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||
@@ -2934,6 +3096,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
httpBackgroundJson,
|
||||
productsBackgroundJobs,
|
||||
productsCache,
|
||||
workloadsCache,
|
||||
spaWorkloads,
|
||||
fusionProducts,
|
||||
authXsrfRecovery,
|
||||
authXsrfCookies,
|
||||
|
||||
558
dist/chromium/content.js
vendored
558
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
166
dist/firefox/background.js
vendored
166
dist/firefox/background.js
vendored
@@ -175,6 +175,8 @@ function createMessageTypes() {
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestWorkloads: "opportunitiesExtension.requestWorkloads",
|
||||
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
|
||||
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||
@@ -923,6 +925,140 @@ function createSpaWorkloadActions({ fetchJson }) {
|
||||
return { requestWlActions };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/workloads.js
|
||||
function createSpaWorkloads({ fetchJson, getCachedWorkloads, normalizeCacheMaxAge, invalidateWorkloadsCache }) {
|
||||
async function requestWorkloadForecastUpdate(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const id = String(message.workloadId ?? "");
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(id)) throw new Error("A numeric workload ID is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
if (!Array.isArray(message.payload) || !message.payload.length) throw new Error("Monthly estimates are required.");
|
||||
const periods = new Set();
|
||||
const payload = message.payload.map(({ year, month, amount, updatedBy }) => {
|
||||
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12
|
||||
|| typeof amount !== "number" || !Number.isFinite(amount) || amount < 0
|
||||
|| typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("Invalid monthly estimate.");
|
||||
const period = `${year}-${month}`;
|
||||
if (periods.has(period)) throw new Error("Duplicate forecast month.");
|
||||
periods.add(period);
|
||||
return { year, month, amount, updatedBy: updatedBy.trim() };
|
||||
});
|
||||
const result = await fetchJson(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads/${id}/forecast`, {
|
||||
method: "PATCH", credentials: "include",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", "wwb-provider-authorization": token.trim() },
|
||||
body: JSON.stringify(payload)
|
||||
}, "requestWorkloadForecastUpdate", debugRequests, { allowEmpty: true });
|
||||
await invalidateWorkloadsCache().catch(() => {});
|
||||
return { ok: true, result, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestWorkloads(message) {
|
||||
const customerId = String(message.customerId ?? "").trim();
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(customerId)) throw new Error("A numeric CustomerAccount.PartyId is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
const result = await getCachedWorkloads(customerId, token.trim(), normalizeCacheMaxAge(message.cacheMaxAgeMs), message.principal);
|
||||
return { ok: true, customerId, ...result };
|
||||
}
|
||||
|
||||
async function fetchAllWorkloads(customerId, token) {
|
||||
const debugRequests = [];
|
||||
const workloads = new Map();
|
||||
let offset = 0;
|
||||
let limit = 500;
|
||||
try {
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = new URL(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers/${customerId}/workloads`);
|
||||
for (const [name, value] of Object.entries({ workloadStatus: "Pipeline,Implementation,Live", opportunityForecastType: "All",
|
||||
includeForEstimates: "ALL", financialYear: "ROLLING_4_QTRS", offset, limit })) url.searchParams.set(name, String(value));
|
||||
const result = await fetchJson(url.toString(), {
|
||||
method: "GET", credentials: "include",
|
||||
headers: { Accept: "application/json", "wwb-provider-authorization": token }
|
||||
}, "requestWorkloads", debugRequests);
|
||||
if (!result || !Array.isArray(result.workloads) || typeof result.hasNext !== "boolean"
|
||||
|| result.workloads.some(item => !item || !/^\d+$/.test(String(item.id ?? "")))) {
|
||||
throw new Error("requestWorkloads returned an invalid collection.");
|
||||
}
|
||||
const countBefore = workloads.size;
|
||||
for (const item of result.workloads) workloads.set(String(item.id), item);
|
||||
if (!result.hasNext) return { workloads: [...workloads.values()], debugRequests };
|
||||
const responseOffset = Number(result.offset ?? offset);
|
||||
const responseLimit = Number(result.limit ?? limit);
|
||||
if (workloads.size === countBefore || !Number.isSafeInteger(responseOffset) || responseOffset !== offset
|
||||
|| !Number.isSafeInteger(responseLimit) || responseLimit <= 0 || !Number.isSafeInteger(responseOffset + responseLimit)) {
|
||||
throw new Error("requestWorkloads pagination did not advance.");
|
||||
}
|
||||
offset = responseOffset + responseLimit;
|
||||
limit = responseLimit;
|
||||
}
|
||||
throw new Error("requestWorkloads exceeded the pagination limit.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestWorkloads, fetchAllWorkloads, requestWorkloadForecastUpdate };
|
||||
}
|
||||
|
||||
// Source: src/features/products/workloads-cache.js
|
||||
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
|
||||
const KEY = "opportunitiesExtension.workloadsCache.v1";
|
||||
let cachePromise = null;
|
||||
let writePromise = Promise.resolve();
|
||||
let generation = 0;
|
||||
const inFlight = new Map();
|
||||
|
||||
function read() {
|
||||
if (!cachePromise) cachePromise = storageLocalGet(KEY)
|
||||
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
|
||||
.catch(() => ({}));
|
||||
return cachePromise;
|
||||
}
|
||||
function write(cache) {
|
||||
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
|
||||
return writePromise;
|
||||
}
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
inFlight.clear();
|
||||
cachePromise = Promise.resolve({});
|
||||
await write({});
|
||||
}
|
||||
async function get(customerId, token, maxAgeMs, principal) {
|
||||
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
|
||||
// isMyWorkload is user-specific. Never persist a result without a known principal.
|
||||
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
|
||||
const key = JSON.stringify([scope, customerId]);
|
||||
const currentGeneration = generation;
|
||||
const cache = await read();
|
||||
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
|
||||
const entry = cache[key];
|
||||
const age = Date.now() - Number(entry?.fetchedAt);
|
||||
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
|
||||
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
|
||||
}
|
||||
if (!inFlight.has(key)) {
|
||||
const flight = fetchAllWorkloads(customerId, token).then(async result => {
|
||||
const cachedAt = Date.now();
|
||||
if (generation === currentGeneration) {
|
||||
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
|
||||
await write(cache).catch(() => {});
|
||||
}
|
||||
return { ...result, cachedAt, fromCache: false };
|
||||
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
|
||||
inFlight.set(key, flight);
|
||||
}
|
||||
return inFlight.get(key);
|
||||
}
|
||||
return { get, invalidate };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/action-options.js
|
||||
function createSpaActionOptions({ fetchJson }) {
|
||||
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||
@@ -2520,6 +2656,9 @@ function registerBackgroundMessages({
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestWorkloads,
|
||||
requestWorkloadForecastUpdate,
|
||||
invalidateWorkloadsCache,
|
||||
requestActionTeamMembers,
|
||||
requestActionFindLookup,
|
||||
requestMgmtAction,
|
||||
@@ -2597,7 +2736,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
@@ -2686,6 +2825,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
|
||||
request(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestWlActions) {
|
||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workload actions.",
|
||||
@@ -2876,6 +3024,17 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const workloadsCache = createWorkloadsCache({
|
||||
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
|
||||
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||
});
|
||||
const spaWorkloads = createSpaWorkloads({
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
getCachedWorkloads: (...args) => workloadsCache.get(...args),
|
||||
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
|
||||
});
|
||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||
@@ -2907,6 +3066,9 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||
@@ -2934,6 +3096,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
httpBackgroundJson,
|
||||
productsBackgroundJobs,
|
||||
productsCache,
|
||||
workloadsCache,
|
||||
spaWorkloads,
|
||||
fusionProducts,
|
||||
authXsrfRecovery,
|
||||
authXsrfCookies,
|
||||
|
||||
558
dist/firefox/content.js
vendored
558
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ function composeScript(target, { bootstrap = true } = {}) {
|
||||
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
|
||||
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
|
||||
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")
|
||||
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css")
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ module.exports = {
|
||||
"src/features/preferences/storage.js",
|
||||
"src/features/preferences/view.js",
|
||||
"src/features/products/content-jobs.js",
|
||||
"src/features/products/workloads.js",
|
||||
"src/features/products/details.js",
|
||||
"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",
|
||||
@@ -97,6 +101,8 @@ module.exports = {
|
||||
"src/services/spa/install-base.js",
|
||||
"src/services/spa/customer-service-requests.js",
|
||||
"src/services/spa/workload-actions.js",
|
||||
"src/services/spa/workloads.js",
|
||||
"src/features/products/workloads-cache.js",
|
||||
"src/services/spa/action-options.js",
|
||||
"src/services/fusion/sr-details.js",
|
||||
"src/services/fusion/sr-create.js",
|
||||
|
||||
@@ -115,6 +115,17 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const workloadsCache = createWorkloadsCache({
|
||||
fetchAllWorkloads: (...args) => spaWorkloads.fetchAllWorkloads(...args),
|
||||
storageLocalGet: (...args) => platformBackgroundApi.storageLocalGet(...args),
|
||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||
});
|
||||
const spaWorkloads = createSpaWorkloads({
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
getCachedWorkloads: (...args) => workloadsCache.get(...args),
|
||||
normalizeCacheMaxAge: value => productsCache.normalizeCacheMaxAge(value)
|
||||
});
|
||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||
@@ -146,6 +157,9 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
requestWorkloads: message => spaWorkloads.requestWorkloads(message),
|
||||
requestWorkloadForecastUpdate: message => spaWorkloads.requestWorkloadForecastUpdate(message),
|
||||
invalidateWorkloadsCache: () => workloadsCache.invalidate(),
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||
@@ -173,6 +187,8 @@ function createBackgroundApplication(overrides = {}) {
|
||||
httpBackgroundJson,
|
||||
productsBackgroundJobs,
|
||||
productsCache,
|
||||
workloadsCache,
|
||||
spaWorkloads,
|
||||
fusionProducts,
|
||||
authXsrfRecovery,
|
||||
authXsrfCookies,
|
||||
|
||||
@@ -17,6 +17,9 @@ function registerBackgroundMessages({
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestWlActions,
|
||||
requestWorkloads,
|
||||
requestWorkloadForecastUpdate,
|
||||
invalidateWorkloadsCache,
|
||||
requestActionTeamMembers,
|
||||
requestActionFindLookup,
|
||||
requestMgmtAction,
|
||||
@@ -94,7 +97,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
Promise.all([invalidateProductsCache(), invalidateWorkloadsCache()]).then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
@@ -183,6 +186,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ([messageTypes.requestWorkloads, messageTypes.requestWorkloadForecastUpdate].includes(message.type)) {
|
||||
const request = message.type === messageTypes.requestWorkloads ? requestWorkloads : requestWorkloadForecastUpdate;
|
||||
request(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workloads.", httpStatus: error.httpStatus,
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestWlActions) {
|
||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load workload actions.",
|
||||
|
||||
@@ -58,7 +58,7 @@ function createContentApplication(overrides = {}) {
|
||||
const contentModal = createContentModal({
|
||||
createManageTimeButton: () => manageTime.createButton(),
|
||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||
closeActionForm: (...args) => manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
closeActionForm: (...args) => rampProjectionView.close(...args) || manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||
@@ -233,6 +233,7 @@ function createContentApplication(overrides = {}) {
|
||||
const opportunitiesController = createOpportunitiesController({
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
||||
resetWorkloads: () => { rampProjectionView.close(false); productWorkloads.reset(); },
|
||||
STAGE_OPTIONS: contracts.STAGE_OPTIONS,
|
||||
STATUS_OPTIONS: contracts.STATUS_OPTIONS,
|
||||
authSessionState,
|
||||
@@ -376,6 +377,7 @@ function createContentApplication(overrides = {}) {
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||
createProductsRampBadge: (...args) => productsDetails.createProductsRampBadge(...args),
|
||||
createProjectionIndicator: (...args) => rampProjectionView.createOpportunityIndicator(...args),
|
||||
createCrmServiceRequestBadge: (...args) => opportunityCrmServiceRequests.createBadge(...args),
|
||||
createProductsDetailRow: (...args) => productsDetails.createProductsDetailRow(...args),
|
||||
createStageBadge: (...args) => opportunitiesLinks.createStageBadge(...args),
|
||||
@@ -475,6 +477,8 @@ function createContentApplication(overrides = {}) {
|
||||
resetRelatedData: () => {
|
||||
opportunityCrmServiceRequests.reset();
|
||||
opportunityWorkloadActions.reset();
|
||||
productWorkloads.reset();
|
||||
rampProjectionView.close(false);
|
||||
consumptionPlansView.closeAccountPlansModal();
|
||||
installBaseView.closeInstallBaseModal();
|
||||
serviceRequestsView.closeServiceRequestsModal();
|
||||
@@ -518,6 +522,8 @@ function createContentApplication(overrides = {}) {
|
||||
}
|
||||
});
|
||||
const productsContentJobs = createProductsContentJobs({
|
||||
loadWorkloads: (...args) => productWorkloads.load(...args),
|
||||
joinWorkloads: number => productWorkloads.joinProducts(number),
|
||||
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
@@ -531,6 +537,14 @@ function createContentApplication(overrides = {}) {
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
environment
|
||||
});
|
||||
const productWorkloads = createProductWorkloads({
|
||||
productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
|
||||
requestConsumerTokenServiceOnce: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
getPrincipal: () => currentUser.getUserId() || authSessionState.principal,
|
||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||
});
|
||||
const workloadActionOptions = createWorkloadActionOptions({
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
@@ -569,6 +583,7 @@ function createContentApplication(overrides = {}) {
|
||||
productsContentState, messageTypes, environment
|
||||
});
|
||||
const productsDetails = createProductsDetails({
|
||||
createProjectionIndicator: (...args) => rampProjectionView.createIndicator(...args),
|
||||
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||
OPPORTUNITIES_COLUMNS: columns,
|
||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||
@@ -578,6 +593,30 @@ function createContentApplication(overrides = {}) {
|
||||
requestGenerateAiSummary: (...args) => aiSummaryContent.requestGenerateAiSummary(...args),
|
||||
environment
|
||||
});
|
||||
const rampProjection = createRampProjection();
|
||||
const workloadForecastUpdate = createWorkloadForecastUpdate({
|
||||
compare: product => rampProjection.compare(product),
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
getCurrentUserId: () => currentUser.getUserId(),
|
||||
requestAuthorization: () => authContentSession.requestConsumerTokenServiceOnce(),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
productsContentState,
|
||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||
});
|
||||
const rampProjectionView = createRampProjectionView({
|
||||
saveEstimates: product => workloadForecastUpdate.save(product),
|
||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
compare: product => rampProjection.compare(product),
|
||||
expandProducts: number => productsDetails.expandProducts(number),
|
||||
createAlertIcon: () => contentIcons.createAlertIcon(),
|
||||
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||
formatAmount: (...args) => productsDetails.formatProductAmount(...args),
|
||||
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||
environment
|
||||
});
|
||||
const aiSummaryContent = createAiSummaryContent({
|
||||
aiSummaryState,
|
||||
invalidateSalesCommonToken: (...args) => authSalesCommon.invalidateSalesCommonToken(...args),
|
||||
@@ -772,12 +811,16 @@ function createContentApplication(overrides = {}) {
|
||||
preferencesStorage,
|
||||
preferencesView,
|
||||
productsContentJobs,
|
||||
productWorkloads,
|
||||
productActivities,
|
||||
activityProgress,
|
||||
srSessionPreparation,
|
||||
refreshAll,
|
||||
activityCache,
|
||||
productsDetails,
|
||||
rampProjection,
|
||||
workloadForecastUpdate,
|
||||
rampProjectionView,
|
||||
aiSummaryContent,
|
||||
consumptionPlansAction,
|
||||
installBaseAction,
|
||||
|
||||
@@ -105,5 +105,47 @@ function createContentIcons({
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon };
|
||||
function createAlertIcon() {
|
||||
/* Lucide triangle-alert (https://lucide.dev/icons/triangle-alert), ISC License.
|
||||
* Copyright (c) 2026 Lucide Icons and Contributors
|
||||
* Permission to use, copy, modify, and/or distribute this software for any
|
||||
* purpose with or without fee is hereby granted, provided that the above
|
||||
* copyright notice and this permission notice appear in all copies.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*
|
||||
* Derived from Feather alert-triangle, MIT License.
|
||||
* Copyright (c) 2013-present Cole Bemis
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
for (const [name, value] of Object.entries({ viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false",
|
||||
fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" })) svg.setAttribute(name, value);
|
||||
for (const d of ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon, createAlertIcon };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ function createOpportunitiesController({
|
||||
requestOpportunitiesPage,
|
||||
resetCrmServiceRequests,
|
||||
resetWorkloadActions,
|
||||
resetWorkloads,
|
||||
requestOpptyProductsInBackground,
|
||||
requestTokenRelayOnce,
|
||||
ensureCurrentUser,
|
||||
@@ -24,6 +25,7 @@ function createOpportunitiesController({
|
||||
const dateRange = getFiscalPeriodRange(period, new Date());
|
||||
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
await ensureCurrentUser();
|
||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||
|
||||
@@ -109,6 +111,7 @@ function createOpportunitiesController({
|
||||
|
||||
function resetOpportunitiesTable() {
|
||||
resetWorkloadActions();
|
||||
resetWorkloads();
|
||||
resetCrmServiceRequests();
|
||||
opportunitiesFilterState.selectedStages = new Set(STAGE_OPTIONS);
|
||||
opportunitiesFilterState.selectedStatuses = new Set(STATUS_OPTIONS);
|
||||
|
||||
@@ -10,6 +10,7 @@ function createOpportunitiesTable({
|
||||
createDetailLink,
|
||||
createProductsCountButton,
|
||||
createProductsRampBadge,
|
||||
createProjectionIndicator,
|
||||
createCrmServiceRequestBadge,
|
||||
closeCrmSrPopover,
|
||||
createWorkloadActionsBadge,
|
||||
@@ -146,6 +147,9 @@ function createOpportunitiesTable({
|
||||
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) {
|
||||
|
||||
@@ -4,11 +4,12 @@ function createFormRequestCache({ getHours, getUserId }) {
|
||||
let generation = 0;
|
||||
let owner = "";
|
||||
const queries = new Set([
|
||||
"requestWorkloads",
|
||||
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
|
||||
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
|
||||
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
|
||||
]);
|
||||
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
|
||||
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest", "requestWorkloadForecastUpdate"]);
|
||||
function invalidate() { generation++; entries.clear(); pending.clear(); }
|
||||
function maxAge() {
|
||||
const hours = Number(getHours());
|
||||
@@ -34,7 +35,10 @@ function createFormRequestCache({ getHours, getUserId }) {
|
||||
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
|
||||
return result;
|
||||
}
|
||||
const key = JSON.stringify(stable(message));
|
||||
// Workloads are scoped to the user/account, not the rotating WWB token or the selected Reference.
|
||||
const key = JSON.stringify(stable(operation === "requestWorkloads"
|
||||
? { type: message.type, customerId: String(message.customerId), principal: String(message.principal || userId).trim().toLowerCase() }
|
||||
: message));
|
||||
const hit = entries.get(key);
|
||||
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
|
||||
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
|
||||
@@ -46,10 +50,14 @@ function createFormRequestCache({ getHours, getUserId }) {
|
||||
}
|
||||
const version = generation;
|
||||
const promise = transport(request).then(result => {
|
||||
if (result?.ok && version === generation) {
|
||||
const workload = operation === "requestWorkloads";
|
||||
const fetchedAt = workload && Number.isFinite(result?.cachedAt) ? result.cachedAt : Date.now();
|
||||
const cacheable = !workload || (Array.isArray(result?.workloads) && (!result.fromCache || Number.isFinite(result.cachedAt)));
|
||||
if (result?.ok && cacheable && version === generation) {
|
||||
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
|
||||
if (entries.size >= 500) entries.delete(entries.keys().next().value);
|
||||
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt: Date.now() });
|
||||
// Reusing a persistent workload entry must not restart its original TTL.
|
||||
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ function createProductsContentJobs({
|
||||
renderOpportunitiesTable,
|
||||
beginProductActivities,
|
||||
completeProductActivities,
|
||||
loadWorkloads,
|
||||
joinWorkloads,
|
||||
sendRuntimeMessage,
|
||||
environment
|
||||
}) {
|
||||
@@ -65,6 +67,8 @@ function createProductsContentJobs({
|
||||
};
|
||||
renderProductsProgress();
|
||||
|
||||
loadWorkloads(opportunities, requestId);
|
||||
|
||||
if (optyNumbers.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -121,6 +125,7 @@ function createProductsContentJobs({
|
||||
cachedAt: Number(message.cachedAt) || 0,
|
||||
error: message.error || ""
|
||||
});
|
||||
joinWorkloads(message.optyNumber);
|
||||
}
|
||||
|
||||
productsContentState.productsProgress = {
|
||||
|
||||
@@ -3,6 +3,8 @@ function createProductsContentState() {
|
||||
currentProductsRequestId: "",
|
||||
productsRenderQueued: false,
|
||||
opptyProductsByNumber: new Map(),
|
||||
workloadCustomerIds: [],
|
||||
workloadsByCustomer: new Map(),
|
||||
expandedProductOptyNumbers: new Set(),
|
||||
productsProgress: {
|
||||
state: "idle",
|
||||
|
||||
@@ -2,6 +2,7 @@ function createProductsDetails({
|
||||
OPPORTUNITIES_COLUMNS,
|
||||
setBadgeContent,
|
||||
createAiSummaryAlert,
|
||||
createProjectionIndicator,
|
||||
formatOracleResponseDate,
|
||||
productsContentState,
|
||||
renderOpportunitiesTable,
|
||||
@@ -24,6 +25,14 @@ function createProductsDetails({
|
||||
return badge;
|
||||
}
|
||||
|
||||
function expandProducts(optyNumber) {
|
||||
const number = String(optyNumber);
|
||||
if (productsContentState.expandedProductOptyNumbers.has(number)) return;
|
||||
productsContentState.expandedProductOptyNumbers.add(number);
|
||||
requestGenerateAiSummary(number);
|
||||
renderOpportunitiesTable();
|
||||
}
|
||||
|
||||
function createProductsCountButton(optyNumber, productsResult) {
|
||||
const normalizedOptyNumber = String(optyNumber);
|
||||
const button = document.createElement("button");
|
||||
@@ -44,8 +53,8 @@ function createProductsDetails({
|
||||
if (isExpanded) {
|
||||
productsContentState.expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||
} else {
|
||||
productsContentState.expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||
requestGenerateAiSummary(normalizedOptyNumber);
|
||||
expandProducts(normalizedOptyNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
renderOpportunitiesTable();
|
||||
@@ -105,7 +114,8 @@ function createProductsDetails({
|
||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
||||
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
|
||||
? "N/D" : formatProductNumber(item.RampMonths_c) }
|
||||
? "N/D" : formatProductNumber(item.RampMonths_c) },
|
||||
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
|
||||
];
|
||||
|
||||
const head = document.createElement("thead");
|
||||
@@ -123,7 +133,10 @@ function createProductsDetails({
|
||||
const productRow = document.createElement("tr");
|
||||
columns.forEach((column) => {
|
||||
const productCell = document.createElement("td");
|
||||
productCell.textContent = String(column.value(product));
|
||||
if (column.render) {
|
||||
productCell.className = "opportunities-extension-projection-cell";
|
||||
productCell.append(column.render(product));
|
||||
} else productCell.textContent = String(column.value(product));
|
||||
productRow.append(productCell);
|
||||
});
|
||||
body.append(productRow);
|
||||
@@ -165,5 +178,5 @@ function createProductsDetails({
|
||||
return Number.isFinite(number) ? `${number}%` : "-";
|
||||
}
|
||||
|
||||
return { createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||
return { expandProducts, createProductsRampBadge, createProductsCountButton, createProductsDetailRow, formatProductNumber, formatProductAmount, formatProductWinProbability };
|
||||
}
|
||||
|
||||
47
src/features/products/forecast-update.js
Normal file
47
src/features/products/forecast-update.js
Normal file
@@ -0,0 +1,47 @@
|
||||
function createWorkloadForecastUpdate({ compare, ensureCurrentUser, getCurrentUserId, requestAuthorization,
|
||||
sendRuntimeMessage, messageTypes, appendBackgroundRequestLog, productsContentState, queueProductsTableRender }) {
|
||||
const pending = new Set();
|
||||
async function save(product) {
|
||||
const id = String(product?.workload?.id ?? "");
|
||||
if (!/^\d+$/.test(id) || id !== String(product.RevnId)) throw new Error("The product does not have a matching workload ID.");
|
||||
if (pending.has(id)) throw new Error("Estimates are already being updated.");
|
||||
const comparison = compare(product);
|
||||
if (comparison.status !== "divergent") throw new Error("No forecast differences are available to update.");
|
||||
pending.add(id);
|
||||
try {
|
||||
await ensureCurrentUser();
|
||||
const updatedBy = getCurrentUserId();
|
||||
if (typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("The current user ID is unavailable.");
|
||||
// Keep zero months before consumption unchanged, unless they also need correcting.
|
||||
const payload = comparison.rows.filter(row => row.index >= 1 || row.divergent)
|
||||
.map(row => ({ year: row.year, month: row.month, amount: row.calculated, updatedBy }));
|
||||
const authorizations = await requestAuthorization();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.requestWorkloadForecastUpdate,
|
||||
workloadId: id, payload, authorization: authorizations?.workloadActions });
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to update workload estimates.");
|
||||
const amounts = new Map(payload.map(item => [`${item.year}-${item.month}`, item.amount]));
|
||||
const update = workload => {
|
||||
if (String(workload?.id) !== id) return workload;
|
||||
return { ...workload, forecast: workload.forecast.map(month => {
|
||||
const key = `${Number(month.year)}-${Number(month.month)}`;
|
||||
return amounts.has(key) ? { ...month, adjustedConsumptionAmount: amounts.get(key) } : month;
|
||||
}) };
|
||||
};
|
||||
product.workload = update(product.workload);
|
||||
for (const record of productsContentState.workloadsByCustomer.values()) {
|
||||
if (record.status !== "ready") continue;
|
||||
record.workloads = record.workloads.map(update);
|
||||
record.byId = new Map(record.workloads.map(workload => [String(workload.id), workload]));
|
||||
}
|
||||
for (const record of productsContentState.opptyProductsByNumber.values()) {
|
||||
record.items = record.items.map(item => ({ ...item, workload: update(item.workload) }));
|
||||
}
|
||||
queueProductsTableRender();
|
||||
return response;
|
||||
} finally {
|
||||
pending.delete(id);
|
||||
}
|
||||
}
|
||||
return { save };
|
||||
}
|
||||
216
src/features/products/ramp-projection-view.js
Normal file
216
src/features/products/ramp-projection-view.js
Normal file
@@ -0,0 +1,216 @@
|
||||
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
function node(tag, className, text) {
|
||||
const item = document.createElement(tag);
|
||||
if (className) item.className = className;
|
||||
if (text !== undefined) item.textContent = text;
|
||||
return item;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
if (active.saving && restoreFocus) return true;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.modal.remove();
|
||||
previous.siblings.forEach(([element, inert]) => { element.inert = inert; });
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function createIndicator(product, opportunity) {
|
||||
const result = compare(product);
|
||||
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;
|
||||
return text;
|
||||
}
|
||||
const button = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert");
|
||||
button.type = "button";
|
||||
button.title = `Consumption forecast differs in ${result.divergentCount} month${result.divergentCount === 1 ? "" : "s"}. View comparison.`;
|
||||
button.setAttribute("aria-label", button.title);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.append(createAlertIcon());
|
||||
button.addEventListener("click", event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
open(product, opportunity, button);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
function createOpportunityIndicator(productsResult, opportunity, isExpanded) {
|
||||
const products = productsResult?.items || [];
|
||||
const count = products.filter(product => compare(product).status === "divergent").length;
|
||||
if (!count) return null;
|
||||
const singleProduct = products.length === 1;
|
||||
const indicator = node("button", "opportunities-extension-icon-button opportunities-extension-projection-alert opportunities-extension-opportunity-projection-alert");
|
||||
indicator.type = "button";
|
||||
indicator.title = `Consumption forecast differs in ${count} product${count === 1 ? "" : "s"}. ${singleProduct ? "View comparison." : "View products."}`;
|
||||
if (singleProduct) indicator.setAttribute("aria-haspopup", "dialog");
|
||||
else indicator.setAttribute("aria-expanded", String(Boolean(isExpanded)));
|
||||
indicator.setAttribute("aria-label", indicator.title);
|
||||
indicator.append(createAlertIcon());
|
||||
indicator.addEventListener("click", event => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (singleProduct) open(products[0], opportunity, indicator);
|
||||
else expandProducts(opportunity.OptyNumber);
|
||||
});
|
||||
return indicator;
|
||||
}
|
||||
function open(product, opportunity, trigger) {
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
const result = compare(product);
|
||||
if (!host || host.hidden || result.status !== "divergent") return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal opportunities-extension-projection-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-projection-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-projection-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header");
|
||||
const heading = node("div", "opportunities-extension-action-form-heading");
|
||||
const title = node("h2", "", "Consumption projection");
|
||||
title.id = "opportunities-extension-projection-title";
|
||||
heading.append(title, node("p", "", `${opportunity.OptyNumber} - ${product.WorkloadName_c || product.workload.name || product.ProdGroupName || "Workload"}`));
|
||||
const dismiss = node("button", "opportunities-extension-icon-button");
|
||||
dismiss.type = "button";
|
||||
dismiss.title = "Close consumption projection";
|
||||
dismiss.setAttribute("aria-label", dismiss.title);
|
||||
dismiss.append(createCloseIcon());
|
||||
dismiss.addEventListener("click", () => close());
|
||||
header.append(heading, dismiss);
|
||||
const body = node("div", "opportunities-extension-projection-body");
|
||||
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`]
|
||||
]) 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`);
|
||||
body.append(summary);
|
||||
if (result.reason) body.append(node("p", "opportunities-extension-projection-notice", result.reason));
|
||||
const viewport = node("div", "opportunities-extension-projection-viewport");
|
||||
viewport.tabIndex = 0;
|
||||
viewport.setAttribute("role", "region");
|
||||
viewport.setAttribute("aria-label", "Monthly consumption comparison");
|
||||
const table = node("table", "opportunities-extension-projection-table");
|
||||
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
|
||||
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
|
||||
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
|
||||
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
|
||||
}
|
||||
head.append(headRow);
|
||||
for (const item of result.rows) {
|
||||
const tr = node("tr");
|
||||
tr.setAttribute("data-divergent", String(item.divergent));
|
||||
const month = node("th", "", `${String(item.month).padStart(2, "0")}/${item.year}`);
|
||||
month.scope = "row";
|
||||
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
|
||||
tr.append(month);
|
||||
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(caption, head, tbody);
|
||||
viewport.append(table);
|
||||
body.append(viewport);
|
||||
const errorMessage = node("p", "opportunities-extension-projection-error");
|
||||
errorMessage.setAttribute("role", "alert");
|
||||
errorMessage.hidden = true;
|
||||
body.append(errorMessage);
|
||||
const footer = node("footer", "opportunities-extension-projection-footer");
|
||||
const updateButton = node("button", "opportunities-extension-projection-update", "Update estimates");
|
||||
updateButton.type = "button";
|
||||
footer.append(node("span", "", "Difference = forecast - calculated."), updateButton);
|
||||
dialog.append(header, body, footer);
|
||||
modal.append(dialog);
|
||||
const siblings = Array.from(host.children).map(element => [element, element.inert]);
|
||||
siblings.forEach(([element]) => { element.inert = true; });
|
||||
const current = { modal, siblings, trigger, saving: false };
|
||||
active = current;
|
||||
async function updateEstimates() {
|
||||
if (current.saving) return;
|
||||
current.saving = true;
|
||||
updateButton.disabled = true;
|
||||
dismiss.disabled = true;
|
||||
updateButton.textContent = "Updating...";
|
||||
modal.setAttribute("aria-busy", "true");
|
||||
errorMessage.hidden = true;
|
||||
try {
|
||||
await saveEstimates(product);
|
||||
current.saving = false;
|
||||
if (active === current) close();
|
||||
notify("Workload estimates updated successfully.");
|
||||
} catch (error) {
|
||||
const message = error.message || "Unable to update workload estimates.";
|
||||
if (active === current) {
|
||||
errorMessage.textContent = message;
|
||||
errorMessage.hidden = false;
|
||||
}
|
||||
notify(message, true);
|
||||
} finally {
|
||||
current.saving = false;
|
||||
updateButton.disabled = false;
|
||||
dismiss.disabled = false;
|
||||
updateButton.textContent = "Update estimates";
|
||||
modal.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
updateButton.addEventListener("click", () => {
|
||||
if (current.saving || current.confirmation) return;
|
||||
const overlay = node("div", "opportunities-extension-action-form-modal");
|
||||
const confirmation = node("section", "opportunities-extension-action-form-dialog opportunities-extension-projection-confirm");
|
||||
confirmation.setAttribute("role", "alertdialog");
|
||||
confirmation.setAttribute("aria-modal", "true");
|
||||
confirmation.setAttribute("aria-labelledby", "projection-confirm-title");
|
||||
confirmation.setAttribute("aria-describedby", "projection-confirm-message");
|
||||
const title = node("h2", "", "Update estimates");
|
||||
title.id = "projection-confirm-title";
|
||||
const message = node("p", "", "The estimates will be updated directly in the SPA using the values in the Calculated column. Would you like to confirm the update?");
|
||||
message.id = "projection-confirm-message";
|
||||
const actions = node("div", "opportunities-extension-projection-confirm-actions");
|
||||
const cancel = node("button", "opportunities-extension-projection-cancel", "Cancel");
|
||||
const confirm = node("button", "opportunities-extension-projection-update", "Confirm update");
|
||||
cancel.type = confirm.type = "button";
|
||||
function dismissConfirmation() {
|
||||
overlay.remove();
|
||||
current.confirmation = null;
|
||||
dialog.inert = false;
|
||||
updateButton.focus();
|
||||
}
|
||||
cancel.addEventListener("click", dismissConfirmation);
|
||||
confirm.addEventListener("click", () => {
|
||||
if (current.confirmation !== overlay || active !== current) return;
|
||||
dismissConfirmation();
|
||||
return updateEstimates();
|
||||
});
|
||||
overlay.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault(); event.stopPropagation(); dismissConfirmation();
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
(document.activeElement === cancel ? confirm : cancel).focus();
|
||||
}
|
||||
});
|
||||
actions.append(cancel, confirm);
|
||||
confirmation.append(title, message, actions);
|
||||
overlay.append(confirmation);
|
||||
current.confirmation = overlay;
|
||||
dialog.inert = true;
|
||||
modal.append(overlay);
|
||||
cancel.focus();
|
||||
});
|
||||
host.append(modal);
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [dismiss, viewport, updateButton].filter(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();
|
||||
}
|
||||
}
|
||||
});
|
||||
dismiss.focus();
|
||||
}
|
||||
return { createIndicator, createOpportunityIndicator, open, close };
|
||||
}
|
||||
52
src/features/products/ramp-projection.css
Normal file
52
src/features/products/ramp-projection.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.opportunities-extension-products-table th:last-child { width: 90px; padding-left: 6px; padding-right: 6px; white-space: nowrap; text-align: center; }
|
||||
.opportunities-extension-products-table .opportunities-extension-projection-cell { text-align: center; vertical-align: middle; }
|
||||
.opportunities-extension-projection-alert { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; color: #976000; width: 30px; height: 30px; flex: 0 0 30px; vertical-align: middle; }
|
||||
.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-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; }
|
||||
.opportunities-extension-projection-body { min-height: 0; overflow: auto; padding: 16px 20px 0; }
|
||||
.opportunities-extension-projection-context { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr); gap: 8px 12px; margin: 0 0 16px; font-size: 12px; }
|
||||
.opportunities-extension-projection-context dt { font-weight: 600; }
|
||||
.opportunities-extension-projection-context dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-projection-summary { font-size: 13px; font-weight: 600; color: #815400; margin: 0 0 12px; }
|
||||
.opportunities-extension-projection-notice { font-size: 12px; }
|
||||
.opportunities-extension-projection-viewport { overflow-x: auto; }
|
||||
.opportunities-extension-projection-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
|
||||
.opportunities-extension-projection-table th:first-child { text-align: left; }
|
||||
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
|
||||
.opportunities-extension-projection-table tbody th { font-weight: 400; }
|
||||
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
|
||||
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
|
||||
.opportunities-extension-projection-prorated { display: inline; color: #625d58; font-size: 10px; }
|
||||
.opportunities-extension-projection-footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0; padding: 14px 20px; font-size: 11px; color: #625d58; }
|
||||
.opportunities-extension-projection-update { flex: none; padding: 8px 12px; border: 1px solid #006879; border-radius: 4px; background: #006879; color: #fff; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.opportunities-extension-projection-update:hover:not(:disabled) { background: #005563; }
|
||||
.opportunities-extension-projection-update:disabled { opacity: .6; cursor: wait; }
|
||||
.opportunities-extension-projection-update:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||
.opportunities-extension-projection-confirm { padding: 20px; }
|
||||
.opportunities-extension-projection-confirm h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.opportunities-extension-projection-confirm p { margin: 0 0 20px; font-size: 13px; line-height: 1.5; }
|
||||
.opportunities-extension-projection-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.opportunities-extension-projection-cancel { padding: 8px 12px; border: 1px solid #8b8580; border-radius: 4px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.opportunities-extension-projection-error { color: #b3261e; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-error { color: #ffb4ab; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-alert,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-summary,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #ffd080; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table thead th { background: #34312e; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table th,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table td { border-color: #504b46; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-table tr[data-divergent="true"] { background: #3e3425; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-prorated,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-projection-footer { color: #c9c3bc; }
|
||||
@media (max-width: 600px) {
|
||||
.opportunities-extension-projection-context { grid-template-columns: max-content minmax(0, 1fr); }
|
||||
.opportunities-extension-projection-dialog > header { padding: 14px 12px; }
|
||||
.opportunities-extension-projection-body { padding: 12px 12px 0; }
|
||||
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 8px 6px; }
|
||||
.opportunities-extension-projection-footer { padding: 12px; }
|
||||
}
|
||||
63
src/features/products/ramp-projection.js
Normal file
63
src/features/products/ramp-projection.js
Normal file
@@ -0,0 +1,63 @@
|
||||
function createRampProjection() {
|
||||
function number(value) {
|
||||
if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") return null;
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) ? result : null;
|
||||
}
|
||||
function startDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/.exec(String(value ?? ""));
|
||||
if (!match) return null;
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
|
||||
return { year, month, day, days: new Date(Date.UTC(year, month, 0)).getUTCDate() };
|
||||
}
|
||||
function monthlyAmount(annual, ramp, month) {
|
||||
if (month < 1) return 0;
|
||||
// calculadora rampa.xlsx, rampas!D6:P17: exact values, before currency rounding.
|
||||
return (2 * annual / (24 - ramp)) * Math.min(month / (ramp + 1), 1);
|
||||
}
|
||||
function cents(value) { return Math.round((value + Number.EPSILON * Math.abs(value)) * 100); }
|
||||
function compare(product) {
|
||||
const workload = product?.workload;
|
||||
const unavailable = reason => ({ status: "unavailable", reason, rows: [], divergentCount: 0 });
|
||||
if (!workload) return unavailable("Workload data is not available for this product.");
|
||||
const annual = number(workload.adjustedACR);
|
||||
const ramp = number(product.RampMonths_c);
|
||||
const start = startDate(workload.consumptionStartDate);
|
||||
if (annual === null || annual < 0) return unavailable("Annual value (adjustedACR) is missing or invalid.");
|
||||
if (ramp === null || !Number.isInteger(ramp) || ramp < 0 || ramp > 11) {
|
||||
return unavailable("RampMonths_c must be an integer from 0 to 11, as covered by the reference workbook.");
|
||||
}
|
||||
if (!start) return unavailable("Consumption start date is missing or invalid.");
|
||||
if (!Array.isArray(workload.forecast) || !workload.forecast.length) return unavailable("Monthly forecast data is not available.");
|
||||
const periods = new Set();
|
||||
const rows = workload.forecast.map(forecast => {
|
||||
const year = number(forecast?.year), month = number(forecast?.month);
|
||||
const actual = number(forecast?.adjustedConsumptionAmount);
|
||||
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12) return null;
|
||||
const key = `${year}-${String(month).padStart(2, "0")}`;
|
||||
if (periods.has(key)) return null;
|
||||
periods.add(key);
|
||||
const index = (year - start.year) * 12 + month - start.month + 1;
|
||||
const prorated = index === 1 && start.day > 1;
|
||||
const exact = monthlyAmount(annual, ramp, index) * (index === 1 ? (start.days - start.day + 1) / start.days : 1);
|
||||
const calculatedCents = cents(exact);
|
||||
const actualCents = actual === null ? null : cents(actual);
|
||||
if (!Number.isSafeInteger(calculatedCents) || (actualCents !== null && !Number.isSafeInteger(actualCents))) return null;
|
||||
const differenceCents = actualCents === null ? null : actualCents - calculatedCents;
|
||||
return { key, year, month, index, prorated, actual: actualCents === null ? null : actualCents / 100,
|
||||
calculated: calculatedCents / 100, difference: differenceCents === null ? null : differenceCents / 100,
|
||||
divergent: differenceCents !== null && differenceCents !== 0 };
|
||||
});
|
||||
if (rows.some(row => row === null)) return unavailable("Forecast contains invalid or duplicate months, or values outside the supported numeric range.");
|
||||
rows.sort((a, b) => a.key.localeCompare(b.key));
|
||||
const divergentCount = rows.filter(row => row.divergent).length;
|
||||
const missingCount = rows.filter(row => row.actual === null).length;
|
||||
return { status: divergentCount ? "divergent" : missingCount ? "unavailable" : "matched",
|
||||
reason: missingCount ? "Some months have no adjusted consumption amount." : "", rows, divergentCount, missingCount,
|
||||
annual, ramp, start: `${start.year}-${String(start.month).padStart(2, "0")}-${String(start.day).padStart(2, "0")}`,
|
||||
firstMonthDays: start.days - start.day + 1, firstMonthTotalDays: start.days };
|
||||
}
|
||||
return { compare, monthlyAmount };
|
||||
}
|
||||
51
src/features/products/workloads-cache.js
Normal file
51
src/features/products/workloads-cache.js
Normal file
@@ -0,0 +1,51 @@
|
||||
function createWorkloadsCache({ fetchAllWorkloads, storageLocalGet, storageLocalSet }) {
|
||||
const KEY = "opportunitiesExtension.workloadsCache.v1";
|
||||
let cachePromise = null;
|
||||
let writePromise = Promise.resolve();
|
||||
let generation = 0;
|
||||
const inFlight = new Map();
|
||||
|
||||
function read() {
|
||||
if (!cachePromise) cachePromise = storageLocalGet(KEY)
|
||||
.then(result => result?.[KEY] && typeof result[KEY] === "object" && !Array.isArray(result[KEY]) ? result[KEY] : {})
|
||||
.catch(() => ({}));
|
||||
return cachePromise;
|
||||
}
|
||||
function write(cache) {
|
||||
writePromise = writePromise.catch(() => {}).then(() => storageLocalSet({ [KEY]: cache }));
|
||||
return writePromise;
|
||||
}
|
||||
async function invalidate() {
|
||||
generation += 1;
|
||||
inFlight.clear();
|
||||
cachePromise = Promise.resolve({});
|
||||
await write({});
|
||||
}
|
||||
async function get(customerId, token, maxAgeMs, principal) {
|
||||
const scope = typeof principal === "string" ? principal.trim().toLowerCase() : "";
|
||||
// isMyWorkload is user-specific. Never persist a result without a known principal.
|
||||
if (!scope) return { ...await fetchAllWorkloads(customerId, token), fromCache: false, cachedAt: Date.now() };
|
||||
const key = JSON.stringify([scope, customerId]);
|
||||
const currentGeneration = generation;
|
||||
const cache = await read();
|
||||
if (generation !== currentGeneration) return get(customerId, token, maxAgeMs, principal);
|
||||
const entry = cache[key];
|
||||
const age = Date.now() - Number(entry?.fetchedAt);
|
||||
if (Array.isArray(entry?.items) && age >= 0 && age < maxAgeMs) {
|
||||
return { workloads: entry.items, cachedAt: Number(entry.fetchedAt), fromCache: true, debugRequests: [] };
|
||||
}
|
||||
if (!inFlight.has(key)) {
|
||||
const flight = fetchAllWorkloads(customerId, token).then(async result => {
|
||||
const cachedAt = Date.now();
|
||||
if (generation === currentGeneration) {
|
||||
cache[key] = { fetchedAt: cachedAt, items: result.workloads };
|
||||
await write(cache).catch(() => {});
|
||||
}
|
||||
return { ...result, cachedAt, fromCache: false };
|
||||
}).finally(() => { if (inFlight.get(key) === flight) inFlight.delete(key); });
|
||||
inFlight.set(key, flight);
|
||||
}
|
||||
return inFlight.get(key);
|
||||
}
|
||||
return { get, invalidate };
|
||||
}
|
||||
78
src/features/products/workloads.js
Normal file
78
src/features/products/workloads.js
Normal file
@@ -0,0 +1,78 @@
|
||||
function createProductWorkloads({ productsContentState, opportunitiesQueryState, preferencesState, messageTypes,
|
||||
requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, getPrincipal, queueProductsTableRender }) {
|
||||
let cycle = null;
|
||||
function reset() {
|
||||
cycle = null;
|
||||
productsContentState.workloadCustomerIds = [];
|
||||
productsContentState.workloadsByCustomer = new Map();
|
||||
}
|
||||
function isCurrent(current) {
|
||||
return cycle === current && current.requestId === productsContentState.currentProductsRequestId
|
||||
&& current.version === opportunitiesQueryState.periodRequestVersion;
|
||||
}
|
||||
function joinProducts(optyNumber) {
|
||||
if (!cycle || !isCurrent(cycle)) return;
|
||||
const key = String(optyNumber);
|
||||
const products = productsContentState.opptyProductsByNumber.get(key);
|
||||
if (!products) return;
|
||||
const customerId = cycle.customerByOpportunity.get(key);
|
||||
const record = productsContentState.workloadsByCustomer.get(customerId);
|
||||
products.items = products.items.map(product => ({ ...product,
|
||||
workload: record?.status === "ready" ? record.byId.get(String(product.RevnId ?? "")) || null : null }));
|
||||
}
|
||||
async function load(opportunities, requestId) {
|
||||
reset();
|
||||
const current = { requestId, version: opportunitiesQueryState.periodRequestVersion,
|
||||
customerByOpportunity: new Map(), opportunitiesByCustomer: new Map() };
|
||||
cycle = current;
|
||||
for (const opportunity of opportunities) {
|
||||
const customerId = String(opportunity?.CustomerAccount?.PartyId ?? "").trim();
|
||||
if (!/^\d+$/.test(customerId)) continue;
|
||||
if (!current.opportunitiesByCustomer.has(customerId)) current.opportunitiesByCustomer.set(customerId, new Set());
|
||||
if (opportunity.OptyNumber) {
|
||||
const number = String(opportunity.OptyNumber);
|
||||
current.customerByOpportunity.set(number, customerId);
|
||||
current.opportunitiesByCustomer.get(customerId).add(number);
|
||||
}
|
||||
}
|
||||
const ids = [...current.opportunitiesByCustomer.keys()];
|
||||
productsContentState.workloadCustomerIds = ids;
|
||||
const records = productsContentState.workloadsByCustomer;
|
||||
for (const id of ids) records.set(id, { status: "loading", workloads: [], byId: new Map() });
|
||||
if (!ids.length || !isCurrent(current)) return;
|
||||
const principal = getPrincipal();
|
||||
const cacheMaxAgeMs = preferencesState.opptyProductsCacheHours * 60 * 60 * 1000;
|
||||
try {
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
if (!isCurrent(current)) return;
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (isCurrent(current) && next < ids.length) {
|
||||
const customerId = ids[next++];
|
||||
const record = records.get(customerId);
|
||||
try {
|
||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWorkloads, customerId,
|
||||
authorization: authorizations?.workloadActions, cacheMaxAgeMs, principal });
|
||||
if (!isCurrent(current)) return;
|
||||
appendBackgroundRequestLog(result?.debugRequests);
|
||||
if (!result?.ok || !Array.isArray(result.workloads)) throw new Error(result?.error || "Unable to load workloads.");
|
||||
Object.assign(record, { status: "ready", workloads: result.workloads,
|
||||
byId: new Map(result.workloads.map(workload => [String(workload.id), workload])),
|
||||
fromCache: Boolean(result.fromCache), cachedAt: Number(result.cachedAt) || 0 });
|
||||
} catch (error) {
|
||||
if (!isCurrent(current)) return;
|
||||
record.status = "error";
|
||||
record.error = error.message || "Unable to load workloads.";
|
||||
}
|
||||
for (const number of current.opportunitiesByCustomer.get(customerId)) joinProducts(number);
|
||||
queueProductsTableRender();
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker));
|
||||
} catch (error) {
|
||||
if (!isCurrent(current)) return;
|
||||
for (const record of records.values()) { record.status = "error"; record.error = error.message || "Unable to load workloads."; }
|
||||
}
|
||||
}
|
||||
return { load, joinProducts, reset };
|
||||
}
|
||||
79
src/services/spa/workloads.js
Normal file
79
src/services/spa/workloads.js
Normal file
@@ -0,0 +1,79 @@
|
||||
function createSpaWorkloads({ fetchJson, getCachedWorkloads, normalizeCacheMaxAge, invalidateWorkloadsCache }) {
|
||||
async function requestWorkloadForecastUpdate(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const id = String(message.workloadId ?? "");
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(id)) throw new Error("A numeric workload ID is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
if (!Array.isArray(message.payload) || !message.payload.length) throw new Error("Monthly estimates are required.");
|
||||
const periods = new Set();
|
||||
const payload = message.payload.map(({ year, month, amount, updatedBy }) => {
|
||||
if (!Number.isInteger(year) || year < 1000 || year > 9999 || !Number.isInteger(month) || month < 1 || month > 12
|
||||
|| typeof amount !== "number" || !Number.isFinite(amount) || amount < 0
|
||||
|| typeof updatedBy !== "string" || !updatedBy.trim()) throw new Error("Invalid monthly estimate.");
|
||||
const period = `${year}-${month}`;
|
||||
if (periods.has(period)) throw new Error("Duplicate forecast month.");
|
||||
periods.add(period);
|
||||
return { year, month, amount, updatedBy: updatedBy.trim() };
|
||||
});
|
||||
const result = await fetchJson(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads/${id}/forecast`, {
|
||||
method: "PATCH", credentials: "include",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", "wwb-provider-authorization": token.trim() },
|
||||
body: JSON.stringify(payload)
|
||||
}, "requestWorkloadForecastUpdate", debugRequests, { allowEmpty: true });
|
||||
await invalidateWorkloadsCache().catch(() => {});
|
||||
return { ok: true, result, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestWorkloads(message) {
|
||||
const customerId = String(message.customerId ?? "").trim();
|
||||
const token = message.authorization?.value;
|
||||
if (!/^\d+$/.test(customerId)) throw new Error("A numeric CustomerAccount.PartyId is required.");
|
||||
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||
const result = await getCachedWorkloads(customerId, token.trim(), normalizeCacheMaxAge(message.cacheMaxAgeMs), message.principal);
|
||||
return { ok: true, customerId, ...result };
|
||||
}
|
||||
|
||||
async function fetchAllWorkloads(customerId, token) {
|
||||
const debugRequests = [];
|
||||
const workloads = new Map();
|
||||
let offset = 0;
|
||||
let limit = 500;
|
||||
try {
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = new URL(`https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers/${customerId}/workloads`);
|
||||
for (const [name, value] of Object.entries({ workloadStatus: "Pipeline,Implementation,Live", opportunityForecastType: "All",
|
||||
includeForEstimates: "ALL", financialYear: "ROLLING_4_QTRS", offset, limit })) url.searchParams.set(name, String(value));
|
||||
const result = await fetchJson(url.toString(), {
|
||||
method: "GET", credentials: "include",
|
||||
headers: { Accept: "application/json", "wwb-provider-authorization": token }
|
||||
}, "requestWorkloads", debugRequests);
|
||||
if (!result || !Array.isArray(result.workloads) || typeof result.hasNext !== "boolean"
|
||||
|| result.workloads.some(item => !item || !/^\d+$/.test(String(item.id ?? "")))) {
|
||||
throw new Error("requestWorkloads returned an invalid collection.");
|
||||
}
|
||||
const countBefore = workloads.size;
|
||||
for (const item of result.workloads) workloads.set(String(item.id), item);
|
||||
if (!result.hasNext) return { workloads: [...workloads.values()], debugRequests };
|
||||
const responseOffset = Number(result.offset ?? offset);
|
||||
const responseLimit = Number(result.limit ?? limit);
|
||||
if (workloads.size === countBefore || !Number.isSafeInteger(responseOffset) || responseOffset !== offset
|
||||
|| !Number.isSafeInteger(responseLimit) || responseLimit <= 0 || !Number.isSafeInteger(responseOffset + responseLimit)) {
|
||||
throw new Error("requestWorkloads pagination did not advance.");
|
||||
}
|
||||
offset = responseOffset + responseLimit;
|
||||
limit = responseLimit;
|
||||
}
|
||||
throw new Error("requestWorkloads exceeded the pagination limit.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { requestWorkloads, fetchAllWorkloads, requestWorkloadForecastUpdate };
|
||||
}
|
||||
@@ -19,6 +19,8 @@ function createMessageTypes() {
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||
requestWorkloads: "opportunitiesExtension.requestWorkloads",
|
||||
requestWorkloadForecastUpdate: "opportunitiesExtension.requestWorkloadForecastUpdate",
|
||||
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||
|
||||
@@ -292,7 +292,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")
|
||||
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.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"));
|
||||
|
||||
11
tests/fixtures/extension.js
vendored
11
tests/fixtures/extension.js
vendored
@@ -2,11 +2,12 @@
|
||||
const listeners = [];
|
||||
const storage = new Map();
|
||||
const internalSrPreview = new URLSearchParams(location.search).has("internal-sr");
|
||||
const projectionPreview = new URLSearchParams(location.search).has("projection");
|
||||
const opportunities = Array.from({ length: 25 }, (_, index) => ({
|
||||
OptyNumber: `TEST${index + 1}`,
|
||||
OptyId: 1000 + index,
|
||||
Name: `Synthetic opportunity ${index + 1} - Database migration`,
|
||||
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview ? 789 : "test-party", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
|
||||
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview || projectionPreview ? 789 : "test-party", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
|
||||
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
|
||||
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
|
||||
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
|
||||
@@ -15,6 +16,7 @@
|
||||
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
|
||||
}));
|
||||
const products = [{ ProdGroupName: "Migrate databases to cloud", WorkloadName_c: "Example workload", RevnAmountCurcyCode: "USD", RevnAmount: "25000", TypeCode: "NEWCOMMIT", StatusCode: "OPEN", WinProb: 60, EffectiveDate: "2026-11-15", ConsumptionStartDate_c: "2026-11-15", RampMonths_c: 3 }];
|
||||
if (projectionPreview) products[0] = { ...products[0], RevnId: 123, RampMonths_c: 2 };
|
||||
const trend = Array.from({ length: 30 }, (_, index) => ({
|
||||
fyDate: `2026-09-${String((index % 28) + 1).padStart(2, "0")}`,
|
||||
fiscalTime: [`${String((index % 28) + 1).padStart(2, "0")}-Sep-26`],
|
||||
@@ -26,10 +28,17 @@
|
||||
accountSummary: { label: "Account-Ti-Authorization", value: "synthetic-accp" },
|
||||
accountPlans: { label: "G2m-Authorization", value: "synthetic-ocicons" }
|
||||
};
|
||||
if (projectionPreview) authorizations.workloadActions = { label: "Wwb-Provider-Authorization", value: "synthetic-wwb" };
|
||||
const runtime = {
|
||||
onMessage: { addListener: listener => listeners.push(listener) },
|
||||
async sendMessage(message) {
|
||||
const operation = message.type.split(".").at(-1);
|
||||
if (operation === "requestWorkloadForecastUpdate") return { ok: true, result: null, debugRequests: [] };
|
||||
if (operation === "requestWorkloads") return { ok: true, workloads: [{ id: 123, name: "Example workload",
|
||||
adjustedACR: 50000, consumptionStartDate: "2026-11-15T00:00:00", forecast: Array.from({ length: 12 }, (_, index) => ({
|
||||
month: (5 + index) % 12 + 1, year: index < 7 ? 2026 : 2027,
|
||||
adjustedConsumptionAmount: index < 5 ? 0 : index === 5 ? 1000 : index === 6 ? 3030.30 : 4500
|
||||
})) }], debugRequests: [] };
|
||||
if (operation === "requestTimeEntries") return { ok: true, items: [], debugRequests: [] };
|
||||
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
|
||||
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
|
||||
|
||||
@@ -34,7 +34,7 @@ test("failed results are never cached and identity changes discard previous resp
|
||||
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
|
||||
});
|
||||
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
|
||||
for (const name of ["requestTimeEntriesSave", "requestTimeEntriesDelete", "requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
|
||||
for (const name of ["requestTimeEntriesSave", "requestTimeEntriesDelete", "requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "requestWorkloadForecastUpdate", "invalidateProductsCache"]) {
|
||||
const h = setup(); let queries = 0, writes = 0;
|
||||
const query = message("requestActionFindLookup");
|
||||
const queryTransport = async () => { queries++; return { ok: true }; };
|
||||
@@ -47,6 +47,42 @@ test("writes and token requests always execute; successful saves and Refresh all
|
||||
for (let i = 0; i < 2; i++) await h.cache.send(message("requestSrTokenCatalog"), async () => { calls++; return { ok: true }; });
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
test("reopening workloads reuses account cache across token renewals and keeps original persistent expiry", async () => {
|
||||
const h = setup(); let calls = 0;
|
||||
h.hours(1);
|
||||
const transport = async () => { calls++; return { ok: true, workloads: [{ id: 1, forecast: [] }], cachedAt: 50000, fromCache: true }; };
|
||||
const msg = message("requestWorkloads", { customerId: 123, principal: "a@example.invalid", authorization: { value: "old-token" } });
|
||||
const first = await h.cache.send(msg, transport);
|
||||
first.workloads[0].forecast.push({ month: 1 });
|
||||
const second = await h.cache.send({ ...msg, customerId: "123", cacheMaxAgeMs: 3600000, authorization: { value: "new-token" } }, transport);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(second.fromCache, true);
|
||||
assert.equal(second.cachedAt, 50000);
|
||||
assert.equal(second.workloads[0].forecast.length, 0);
|
||||
h.advance(3550000);
|
||||
await h.cache.send(msg, transport);
|
||||
assert.equal(calls, 2, "expires from persistent fetchedAt rather than modal opening time");
|
||||
await h.cache.send({ ...msg, customerId: 456 }, transport);
|
||||
assert.equal(calls, 3);
|
||||
h.user("b@example.invalid");
|
||||
await h.cache.send({ ...msg, principal: "b@example.invalid" }, transport);
|
||||
assert.equal(calls, 4);
|
||||
});
|
||||
test("workload cache survives failed updates but invalidates after success and Refresh all", async () => {
|
||||
const h = setup(); let calls = 0;
|
||||
const msg = message("requestWorkloads", { customerId: 123 });
|
||||
const transport = async () => { calls++; return { ok: true, workloads: [] }; };
|
||||
await h.cache.send(msg, transport);
|
||||
await h.cache.send(message("requestWorkloadForecastUpdate"), async () => ({ ok: false }));
|
||||
await h.cache.send(msg, transport);
|
||||
assert.equal(calls, 1);
|
||||
await h.cache.send(message("requestWorkloadForecastUpdate"), async () => ({ ok: true }));
|
||||
await h.cache.send(msg, transport);
|
||||
assert.equal(calls, 2);
|
||||
await h.cache.send(message("invalidateProductsCache"), async () => ({ ok: true }));
|
||||
await h.cache.send(msg, transport);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
test("concurrent queries share work but old responses cannot repopulate an invalidated cache", async () => {
|
||||
const h = setup(); let resolve, calls = 0;
|
||||
const msg = message("requestLookupTypePlatform");
|
||||
|
||||
@@ -132,6 +132,32 @@ 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", () => {
|
||||
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",
|
||||
forecast: [{ year: 2026, month: 10, adjustedConsumptionAmount: amount }] } });
|
||||
assert.equal(alert(), undefined);
|
||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(90), product(100), product(110), {}] });
|
||||
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(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);
|
||||
});
|
||||
|
||||
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
|
||||
@@ -254,6 +280,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.ok(remaining > 0);
|
||||
assert.ok(remaining / 2 >= 12 + 84, "three 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"].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"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
209
tests/ramp-projection.test.cjs
Normal file
209
tests/ramp-projection.test.cjs
Normal file
@@ -0,0 +1,209 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const model = loadApplication("content").rampProjection;
|
||||
const product = (workload = {}, overrides = {}) => ({ RampMonths_c: 2, RevnAmount: 1,
|
||||
ConsumptionStartDate_c: "2000-01-01", workload: { adjustedACR: 50000,
|
||||
consumptionStartDate: "2026-11-01T00:00:00", forecast: [{ month: 11, year: 2026, adjustedConsumptionAmount: 1515.15 }],
|
||||
...workload }, ...overrides });
|
||||
|
||||
test("ramp formula agrees with all twelve workbook ramps and preserves the annual total", () => {
|
||||
// rampas!E6:E17 and the stabilized values in P6:P17, annual value F2 = 50000.
|
||||
const first = [4166.666666666667, 2173.913043478261, 1515.151515151515, 1190.4761904761904, 1000,
|
||||
877.1929824561404, 793.6507936507936, 735.2941176470588, 694.4444444444445, 666.6666666666667,
|
||||
649.3506493506494, 641.025641025641];
|
||||
const stable = [4166.666666666667, 4347.826086956522, 4545.454545454545, 4761.9047619047615,
|
||||
5000, 5263.1578947368425, 5555.555555555556, 5882.35294117647, 6250, 6666.666666666667,
|
||||
7142.857142857143, 7692.307692307692];
|
||||
for (let ramp = 0; ramp <= 11; ramp++) {
|
||||
assert.ok(Math.abs(model.monthlyAmount(50000, ramp, 1) - first[ramp]) < 1e-9);
|
||||
assert.ok(Math.abs(model.monthlyAmount(50000, ramp, 12) - stable[ramp]) < 1e-9);
|
||||
const months = Array.from({ length: 12 }, (_, i) => model.monthlyAmount(50000, ramp, i + 1));
|
||||
assert.ok(Math.abs(months.reduce((sum, value) => sum + value, 0) - 50000) < 1e-9);
|
||||
assert.equal(model.monthlyAmount(50000, ramp, 13), months[11], "no annual restart");
|
||||
assert.equal(model.monthlyAmount(50000, ramp, 0), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("comparison uses adjustedACR, product RampMonths_c, calendar months and cent precision", () => {
|
||||
const result = model.compare(product({ rampMonths: 0, actualRampMonths: 11, forecast: [
|
||||
{ month: 1, year: 2027, adjustedConsumptionAmount: 4500 },
|
||||
{ month: 11, year: 2026, adjustedConsumptionAmount: "1515.151515" },
|
||||
{ month: 12, year: 2026, adjustedConsumptionAmount: 3200 },
|
||||
{ month: 10, year: 2026, adjustedConsumptionAmount: 0 }
|
||||
] }));
|
||||
assert.equal(result.status, "divergent");
|
||||
assert.equal(result.divergentCount, 2);
|
||||
assert.deepEqual(Array.from(result.rows, row => [row.key, row.calculated, row.difference]), [
|
||||
["2026-10", 0, 0], ["2026-11", 1515.15, 0], ["2026-12", 3030.30, 169.70], ["2027-01", 4545.45, -45.45]
|
||||
]);
|
||||
assert.equal(model.compare(product()).status, "matched");
|
||||
});
|
||||
|
||||
test("first month is prorated inclusively without timezone shifts, including leap years", () => {
|
||||
for (const [start, month, year, expected, days, total] of [
|
||||
["2026-11-25T00:00:00-0300", 11, 2026, 303.03, 6, 30],
|
||||
["2026-11-30T00:00:00Z", 11, 2026, 50.51, 1, 30],
|
||||
["2028-02-29T00:00:00+1400", 2, 2028, 52.25, 1, 29],
|
||||
["2027-02-28", 2, 2027, 54.11, 1, 28],
|
||||
["2026-12-01", 12, 2026, 1515.15, 31, 31]
|
||||
]) {
|
||||
const result = model.compare(product({ consumptionStartDate: start,
|
||||
forecast: [{ month, year, adjustedConsumptionAmount: expected }] }));
|
||||
assert.equal(result.rows[0].calculated, expected, start);
|
||||
assert.equal(result.status, "matched", start);
|
||||
assert.equal(result.firstMonthDays, days);
|
||||
assert.equal(result.firstMonthTotalDays, total);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing or invalid inputs never create false mismatches or silently default to zero", () => {
|
||||
for (const value of [null, "", "garbage", -1, Infinity]) assert.equal(model.compare(product({ adjustedACR: value })).status, "unavailable");
|
||||
for (const value of [null, "", "garbage", -1, 12, 1.5]) assert.equal(model.compare(product({}, { RampMonths_c: value })).status, "unavailable");
|
||||
for (const value of [null, "", "2026-02-30", "2026-13-01"]) assert.equal(model.compare(product({ consumptionStartDate: value })).status, "unavailable");
|
||||
assert.equal(model.compare({}).status, "unavailable");
|
||||
for (const forecast of [[], null, [{ year: 2026, month: 0 }], [{ year: 2026, month: 11 }, { year: 2026, month: 11 }]]) {
|
||||
assert.equal(model.compare(product({ forecast })).status, "unavailable");
|
||||
}
|
||||
assert.equal(model.compare(product({ adjustedACR: 0, forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] }, { RampMonths_c: "0" })).status, "matched");
|
||||
const missing = model.compare(product({ forecast: [{ year: 2026, month: 11, projectedConsumptionAmount: 500 }] }));
|
||||
assert.equal(missing.status, "unavailable");
|
||||
assert.equal(missing.rows[0].actual, null);
|
||||
assert.equal(missing.rows[0].difference, null);
|
||||
});
|
||||
|
||||
test("calculation is read-only and keeps missing monthly values visible beside real differences", () => {
|
||||
const input = product({ forecast: [{ year: 2026, month: 11 }, { year: 2026, month: 12, adjustedConsumptionAmount: 0 }] });
|
||||
const before = JSON.stringify(input);
|
||||
const result = model.compare(input);
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
assert.equal(result.status, "divergent");
|
||||
assert.equal(result.divergentCount, 1);
|
||||
assert.equal(result.missingCount, 1);
|
||||
assert.ok(result.reason);
|
||||
});
|
||||
|
||||
function dom() {
|
||||
const document = { activeElement: null };
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.dataset = {}; this.listeners = {}; this.classList = { toggle() {} }; this.inert = false; this.isConnected = true; }
|
||||
set textContent(value) { this.text = String(value); this.children = []; }
|
||||
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
|
||||
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
|
||||
setAttribute(name, value) { this.attributes[name] = value; }
|
||||
addEventListener(name, fn) { this.listeners[name] = fn; }
|
||||
focus() { document.activeElement = this; }
|
||||
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
|
||||
click() { this.listeners.click?.({ preventDefault() {}, stopPropagation() {} }); }
|
||||
}
|
||||
const host = new Element("main"), content = new Element("div");
|
||||
host.append(content);
|
||||
document.createElement = tag => new Element(tag);
|
||||
document.createElementNS = (_, tag) => new Element(tag);
|
||||
document.getElementById = () => host;
|
||||
return { document, host, content };
|
||||
}
|
||||
const walk = root => [root, ...root.children.flatMap(walk)];
|
||||
|
||||
test("Update estimates prevents duplicate clicks, reports errors, retries and closes on success", async () => {
|
||||
const { document, host } = dom();
|
||||
const app = loadApplication("content", { document, Intl, Date });
|
||||
const input = product({ forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] });
|
||||
const notifications = [];
|
||||
app.opportunitiesLinks.showCopyConfirmation = (...args) => notifications.push(args);
|
||||
let reject, calls = 0;
|
||||
app.workloadForecastUpdate.save = () => { calls++; return new Promise((_, fail) => { reject = fail; }); };
|
||||
app.rampProjectionView.open(input, { OptyNumber: "TEST" });
|
||||
const modal = host.children[1];
|
||||
const button = walk(modal).find(node => node.textContent === "Update estimates");
|
||||
const event = { preventDefault() {}, stopPropagation() {} };
|
||||
button.listeners.click(event);
|
||||
assert.equal(calls, 0, "opening confirmation must not save");
|
||||
const confirmation = () => walk(modal).find(node => node.attributes.role === "alertdialog");
|
||||
assert.ok(confirmation().textContent.includes("The estimates will be updated directly in the SPA"));
|
||||
const cancel = walk(confirmation()).find(node => node.textContent === "Cancel");
|
||||
assert.equal(document.activeElement, cancel);
|
||||
cancel.click();
|
||||
assert.equal(confirmation(), undefined);
|
||||
assert.equal(document.activeElement, button);
|
||||
assert.equal(calls, 0, "cancelling must not save");
|
||||
button.click();
|
||||
const pending = walk(confirmation()).find(node => node.textContent === "Confirm update").listeners.click(event);
|
||||
button.click();
|
||||
assert.equal(confirmation(), undefined);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(button.disabled, true);
|
||||
assert.equal(app.rampProjectionView.close(), true);
|
||||
assert.equal(host.children.length, 2, "cannot dismiss while saving");
|
||||
reject(new Error("Forbidden"));
|
||||
await pending;
|
||||
assert.equal(button.disabled, false);
|
||||
assert.deepEqual(notifications, [["Forbidden", true]]);
|
||||
app.workloadForecastUpdate.save = async () => { calls++; };
|
||||
button.click();
|
||||
await walk(confirmation()).find(node => node.textContent === "Confirm update").listeners.click(event);
|
||||
assert.equal(host.children.length, 1);
|
||||
assert.deepEqual(notifications[1], ["Workload estimates updated successfully."]);
|
||||
});
|
||||
|
||||
test("single-product opportunity alert opens the comparison directly with opportunity context", () => {
|
||||
const { document, host, content } = dom();
|
||||
const app = loadApplication("content", { document, Intl, Date });
|
||||
const opportunity = { OptyNumber: "ONLY", CustomerAccount: { PartyUniqueName: "Single customer" } };
|
||||
const input = product({ forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 0 }] });
|
||||
let expansions = 0;
|
||||
app.productsDetails.expandProducts = () => { expansions++; };
|
||||
const alert = app.rampProjectionView.createOpportunityIndicator({ items: [input] }, opportunity, false);
|
||||
content.append(alert);
|
||||
assert.equal(alert.attributes["aria-haspopup"], "dialog");
|
||||
assert.equal(alert.attributes["aria-expanded"], undefined);
|
||||
assert.match(alert.title, /View comparison/);
|
||||
alert.click();
|
||||
assert.equal(expansions, 0);
|
||||
assert.equal(host.children[1].attributes.role, "dialog");
|
||||
assert.ok(host.children[1].textContent.includes("ONLY"));
|
||||
assert.ok(host.children[1].textContent.includes("Single customer"));
|
||||
app.rampProjectionView.close();
|
||||
assert.equal(document.activeElement, alert);
|
||||
const multiple = app.rampProjectionView.createOpportunityIndicator({ items: [input, product()] }, opportunity, false);
|
||||
assert.equal(multiple.attributes["aria-haspopup"], undefined);
|
||||
multiple.click();
|
||||
assert.equal(expansions, 1, "two products still expand even when only one is divergent");
|
||||
assert.equal(host.children.length, 1);
|
||||
});
|
||||
|
||||
test("product column opens accessible read-only comparison and restores focus/background on close", () => {
|
||||
const { document, host, content } = dom();
|
||||
const app = loadApplication("content", { document, Intl, Date });
|
||||
const input = product({ consumptionStartDate: "2026-11-25", forecast: [{ year: 2026, month: 11, adjustedConsumptionAmount: 1000 }] },
|
||||
{ RevnId: 1, ProdGroupName: "<b>Product</b>", RevnAmountCurcyCode: "USD" });
|
||||
app.productsContentState.opptyProductsByNumber.set("TEST", { items: [input] });
|
||||
const row = app.productsDetails.createProductsDetailRow({ OptyNumber: "TEST", CustomerAccount: { PartyUniqueName: "Example" } }, 10);
|
||||
const alert = walk(row).find(node => node.attributes["aria-haspopup"] === "dialog");
|
||||
assert.ok(walk(row).some(node => node.tag === "th" && node.textContent === "Projection"));
|
||||
assert.ok(alert);
|
||||
assert.equal(alert.children[0].tag, "svg");
|
||||
content.append(row);
|
||||
alert.click();
|
||||
const modal = host.children[1];
|
||||
assert.equal(modal.attributes.role, "dialog");
|
||||
assert.equal(content.inert, true);
|
||||
const elements = walk(modal);
|
||||
for (const text of ["Forecast", "Calculated", "Difference", "11/2026 (Prorated)", "$303.03", "$696.97"]) {
|
||||
assert.ok(elements.some(node => node.textContent === text), text);
|
||||
}
|
||||
assert.ok(elements.some(node => node.textContent === "<b>Product</b>"));
|
||||
assert.equal(elements.some(node => node.tag === "b"), false, "names use safe text nodes");
|
||||
const closeButton = elements.find(node => node.title === "Close consumption projection");
|
||||
assert.equal(document.activeElement, closeButton);
|
||||
let prevented = false;
|
||||
modal.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(document.activeElement.textContent, "Update estimates");
|
||||
modal.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||
assert.equal(host.children.length, 1);
|
||||
assert.equal(content.inert, false);
|
||||
assert.equal(document.activeElement, alert);
|
||||
assert.equal(app.rampProjectionView.createIndicator(product(), {}).textContent, "OK");
|
||||
assert.equal(app.rampProjectionView.createIndicator({}, {}).textContent, "-");
|
||||
});
|
||||
102
tests/workload-forecast-update.test.cjs
Normal file
102
tests/workload-forecast-update.test.cjs
Normal file
@@ -0,0 +1,102 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { loadBackground, response, plain } = require("./support/characterization.cjs");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const type = "opportunitiesExtension.requestWorkloadForecastUpdate";
|
||||
const payload = [{ year: 2026, month: 11, amount: 303.03, updatedBy: "test@example.invalid" }];
|
||||
const message = { type, workloadId: "1234", authorization: { value: "synthetic-wwb" }, payload };
|
||||
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: forecast PATCH uses dynamic workload, JSON, WWB authorization and invalidates cache on 204`, async () => {
|
||||
const calls = [];
|
||||
const storage = { "opportunitiesExtension.workloadsCache.v1": { old: { items: [{ id: 1234 }] } } };
|
||||
const h = loadBackground({ family, storage, fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(null, { status: 204 });
|
||||
} });
|
||||
const result = await h.dispatch(message);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(calls[0].url.endsWith("/workloads/1234/forecast"));
|
||||
assert.equal(calls[0].options.method, "PATCH");
|
||||
assert.equal(calls[0].options.credentials, "include");
|
||||
assert.equal(calls[0].options.headers["wwb-provider-authorization"], "synthetic-wwb");
|
||||
assert.equal(calls[0].options.headers["Content-Type"], "application/json");
|
||||
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
|
||||
assert.deepEqual(storage["opportunitiesExtension.workloadsCache.v1"], {});
|
||||
assert.equal(result.debugRequests[0].name, "requestWorkloadForecastUpdate");
|
||||
assert.equal(result.debugRequests[0].httpStatus, 204);
|
||||
});
|
||||
|
||||
test(`${family}: denied PATCH preserves cache, returns debug and is not automatically retried`, async () => {
|
||||
let calls = 0;
|
||||
const storage = { "opportunitiesExtension.workloadsCache.v1": { old: { items: [{ id: 1234 }] } } };
|
||||
const before = JSON.stringify(storage);
|
||||
const h = loadBackground({ family, storage, fetch: async () => { calls++; return response({ error: "Denied" }, 403); } });
|
||||
const result = await h.dispatch(message);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.httpStatus, 403);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(JSON.stringify(storage), before);
|
||||
assert.equal(result.debugRequests[0].httpStatus, 403);
|
||||
});
|
||||
}
|
||||
|
||||
test("forecast update rejects invalid identity, invalid amounts and duplicate months before fetch", async () => {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ fetch: async () => { calls++; return response({}); } });
|
||||
for (const change of [{ workloadId: "../other" }, { authorization: {} }, { payload: [] },
|
||||
{ payload: [{ ...payload[0], amount: -1 }] }, { payload: [{ ...payload[0], month: 13 }] },
|
||||
{ payload: [{ ...payload[0], updatedBy: "" }] }, { payload: [payload[0], payload[0]] }]) {
|
||||
assert.equal((await h.dispatch({ ...message, ...change })).ok, false);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const app = loadApplication("content");
|
||||
const item = { RevnId: 1234, RampMonths_c: 2, workload: { id: 1234, adjustedACR: 50000,
|
||||
consumptionStartDate: "2026-11-25", forecast: [
|
||||
{ year: 2026, month: 10, adjustedConsumptionAmount: 0 },
|
||||
{ year: 2026, month: 11, adjustedConsumptionAmount: 100 },
|
||||
{ year: 2026, month: 12, adjustedConsumptionAmount: 100 }
|
||||
] } };
|
||||
app.currentUser.ensure = async () => {};
|
||||
app.currentUser.getUserId = () => "test@example.invalid";
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: message.authorization });
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||
app.productsContentState.opptyProductsByNumber.set("ONE", { items: [item] });
|
||||
app.productsContentState.opptyProductsByNumber.set("TWO", { items: [{ ...item }] });
|
||||
app.productsContentState.workloadsByCustomer.set("99", { status: "ready", workloads: [item.workload], byId: new Map([["1234", item.workload]]) });
|
||||
return { app, item };
|
||||
}
|
||||
|
||||
test("content constructs calculated months and updates both joins only after successful PATCH", async () => {
|
||||
const { app, item } = setup();
|
||||
let release, sent;
|
||||
app.platformContentMessages.sendRuntimeMessage = value => { sent = value; return new Promise(resolve => { release = resolve; }); };
|
||||
const saving = app.workloadForecastUpdate.save(item);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(item.workload.forecast[1].adjustedConsumptionAmount, 100);
|
||||
assert.equal(sent.type, type);
|
||||
assert.equal(sent.workloadId, "1234");
|
||||
assert.deepEqual(plain(sent.payload), [payload[0], { year: 2026, month: 12, amount: 3030.30, updatedBy: "test@example.invalid" }]);
|
||||
await assert.rejects(app.workloadForecastUpdate.save(item), /already being updated/);
|
||||
release({ ok: true });
|
||||
await saving;
|
||||
assert.equal(app.rampProjection.compare(item).status, "matched");
|
||||
assert.equal(app.productsContentState.workloadsByCustomer.get("99").byId.get("1234").forecast[1].adjustedConsumptionAmount, 303.03);
|
||||
for (const record of app.productsContentState.opptyProductsByNumber.values()) assert.equal(app.rampProjection.compare(record.items[0]).status, "matched");
|
||||
});
|
||||
|
||||
test("failed forecast update leaves values unchanged and allows a deliberate retry", async () => {
|
||||
const { app, item } = setup();
|
||||
const before = JSON.stringify(item);
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false, error: "Forbidden" });
|
||||
await assert.rejects(app.workloadForecastUpdate.save(item), /Forbidden/);
|
||||
assert.equal(JSON.stringify(item), before);
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||
await app.workloadForecastUpdate.save(item);
|
||||
assert.equal(app.rampProjection.compare(item).status, "matched");
|
||||
});
|
||||
237
tests/workloads.test.cjs
Normal file
237
tests/workloads.test.cjs
Normal file
@@ -0,0 +1,237 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { loadBackground, plain, response } = require("./support/characterization.cjs");
|
||||
const { loadApplication } = require("./support/application.cjs");
|
||||
const TYPE = "opportunitiesExtension.requestWorkloads";
|
||||
const CACHE = "opportunitiesExtension.workloadsCache.v1";
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
const request = (extra = {}) => ({ type: TYPE, customerId: "123", principal: "user@example.invalid",
|
||||
authorization: { value: "synthetic-wwb-token" }, cacheMaxAgeMs: 3600000, ...extra });
|
||||
const page = (ids, extra = {}) => ({ workloads: ids.map(id => ({ id, name: `Workload ${id}`, forecast: [] })), hasNext: false, ...extra });
|
||||
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: workloads paginate with response offset/limit and reuse persistent per-account cache`, async () => {
|
||||
const calls = [], storage = {};
|
||||
const h = loadBackground({ family, storage, fetch: async (url, options) => {
|
||||
calls.push({ url: new URL(url), options });
|
||||
const offset = Number(new URL(url).searchParams.get("offset"));
|
||||
return response(offset === 0 ? page([11, 12], { hasNext: true, offset: 0, limit: 2 })
|
||||
: offset === 2 ? page([13], { hasNext: true, offset: 2, limit: 1 }) : page([14]));
|
||||
} });
|
||||
const result = await h.dispatch(request());
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.workloads.map(item => item.id), [11, 12, 13, 14]);
|
||||
assert.deepEqual(calls.map(call => call.url.searchParams.get("offset")), ["0", "2", "3"]);
|
||||
for (const { url, options } of calls) {
|
||||
assert.equal(url.origin, "https://spa.oracle.com");
|
||||
assert.ok(url.pathname.endsWith("/customers/123/workloads"));
|
||||
assert.equal(url.searchParams.get("workloadStatus"), "Pipeline,Implementation,Live");
|
||||
assert.equal(url.searchParams.get("opportunityForecastType"), "All");
|
||||
assert.equal(url.searchParams.get("includeForEstimates"), "ALL");
|
||||
assert.equal(url.searchParams.get("financialYear"), "ROLLING_4_QTRS");
|
||||
assert.equal(options.method, "GET");
|
||||
assert.equal(options.credentials, "include");
|
||||
assert.equal(options.headers["wwb-provider-authorization"], "synthetic-wwb-token");
|
||||
}
|
||||
assert.equal(result.debugRequests.length, 3);
|
||||
assert.ok(result.debugRequests.every(entry => entry.name === "requestWorkloads"));
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.equal((await h.dispatch(request())).fromCache, true);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.doesNotMatch(JSON.stringify(storage), /synthetic-wwb-token|debugRequests|requestHeaders/);
|
||||
const restarted = loadBackground({ family, storage });
|
||||
const cached = await restarted.dispatch(request());
|
||||
assert.equal(cached.fromCache, true);
|
||||
assert.deepEqual(cached.workloads, result.workloads);
|
||||
assert.equal(cached.cachedAt, result.cachedAt);
|
||||
h.advance(3600001);
|
||||
assert.equal((await h.dispatch(request())).fromCache, false);
|
||||
assert.equal(calls.length, 6);
|
||||
assert.equal((await h.dispatch(request({ customerId: "456" }))).fromCache, false);
|
||||
assert.equal((await h.dispatch(request({ principal: "other@example.invalid" }))).fromCache, false);
|
||||
assert.equal(calls.length, 12);
|
||||
});
|
||||
|
||||
test(`${family}: workload cache coalesces flights and Refresh all prevents late cache restoration`, async () => {
|
||||
const pending = [], storage = {};
|
||||
const h = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
|
||||
const old = h.dispatch(request()), shared = h.dispatch(request());
|
||||
await flush();
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal((await h.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" })).ok, true);
|
||||
const fresh = h.dispatch(request());
|
||||
await flush();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[1](response(page([22])));
|
||||
await fresh;
|
||||
pending[0](response(page([11])));
|
||||
assert.deepEqual(await old, await shared);
|
||||
const cached = await h.dispatch(request());
|
||||
assert.equal(cached.fromCache, true);
|
||||
assert.deepEqual(cached.workloads.map(item => item.id), [22]);
|
||||
assert.equal(pending.length, 2);
|
||||
});
|
||||
|
||||
test(`${family}: successful empty workloads are cached; storage failures do not lose results`, async () => {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ family, fetch: async () => { calls++; return response(page([])); } });
|
||||
assert.deepEqual((await h.dispatch(request())).workloads, []);
|
||||
assert.equal((await h.dispatch(request())).fromCache, true);
|
||||
assert.equal(calls, 1);
|
||||
const unavailable = loadBackground({ family, storageError: true, fetch: async () => response(page([10])) });
|
||||
assert.equal((await unavailable.dispatch(request())).ok, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("workload failures and stalled pagination are not cached as complete results", async () => {
|
||||
for (const bad of [response({}, 403), response({ workloads: [] }), response(page([], { hasNext: true })),
|
||||
response(page([11], { hasNext: true, offset: 0, limit: 0 }))]) {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ fetch: async () => ++calls === 1 ? bad : response(page([12])) });
|
||||
const failed = await h.dispatch(request());
|
||||
assert.equal(failed.ok, false);
|
||||
assert.equal(failed.debugRequests.length, 1);
|
||||
assert.equal((await h.dispatch(request())).ok, true);
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
let calls = 0;
|
||||
const repeated = loadBackground({ fetch: async () => { calls++; return response(page([11], { hasNext: true })); } });
|
||||
assert.match((await repeated.dispatch(request())).error, /did not advance/);
|
||||
assert.equal(calls, 2);
|
||||
let attempt = 0;
|
||||
const partial = loadBackground({ fetch: async () => ++attempt === 1
|
||||
? response(page([11], { hasNext: true })) : response({}, 500) });
|
||||
const failure = await partial.dispatch(request());
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.debugRequests.length, 2);
|
||||
assert.equal(Object.keys(partial.storage[CACHE] || {}).length, 0);
|
||||
});
|
||||
|
||||
test("workload validation rejects unsafe IDs or missing WWB tokens before requesting", async () => {
|
||||
const h = loadBackground();
|
||||
for (const extra of [{ customerId: "../123" }, { customerId: "" }, { authorization: {} }]) {
|
||||
const result = await h.dispatch(request(extra));
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.debugRequests, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("workloads without a known principal are never persisted or reused across users", async () => {
|
||||
const storage = {};
|
||||
let calls = 0;
|
||||
const h = loadBackground({ storage, fetch: async () => { calls++; return response(page([11])); } });
|
||||
await h.dispatch(request({ principal: "" }));
|
||||
await h.dispatch(request({ principal: "" }));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(storage[CACHE], undefined);
|
||||
});
|
||||
|
||||
function content() {
|
||||
let listener;
|
||||
const app = loadApplication("content", {
|
||||
document: { getElementById: () => null }, window: { requestAnimationFrame() {} },
|
||||
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
|
||||
});
|
||||
const calls = [], logs = [];
|
||||
let tokens = 0;
|
||||
app.currentUser.getUserId = () => "user@example.invalid";
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => { tokens++; return { workloadActions: { value: "wwb" } }; };
|
||||
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(...(entries || []));
|
||||
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||
if (message.type !== TYPE) return Promise.resolve({ ok: true });
|
||||
return new Promise(resolve => calls.push({ message: plain(message), resolve }));
|
||||
};
|
||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||
const start = opportunities => {
|
||||
app.productsContentJobs.requestOpptyProductsInBackground(opportunities, "fusion-token");
|
||||
return app.productsContentState.currentProductsRequestId;
|
||||
};
|
||||
const progress = (id, number, items, fromCache = false) => listener({ type: "opportunitiesExtension.opptyProductsProgress",
|
||||
requestId: id, optyNumber: number, items, fromCache, state: "running" });
|
||||
return { app, calls, logs, start, progress, tokens: () => tokens };
|
||||
}
|
||||
const opportunity = (OptyNumber, PartyId) => ({ OptyNumber, CustomerAccount: { PartyId } });
|
||||
|
||||
for (const productsFirst of [true, false]) {
|
||||
test(`accounts deduplicate independently of filters; products-first=${productsFirst} joins by account and RevnId`, async () => {
|
||||
const h = content();
|
||||
h.app.preferencesState.opptyProductsCacheHours = 12;
|
||||
h.app.opportunitiesFilterState.tableSearch = "hides all opportunities";
|
||||
const id = h.start([opportunity("A", 123), opportunity("B", "123"), opportunity("C", 456),
|
||||
{ OptyNumber: "NO_ACCOUNT", PartyId: 999, Owner: { PartyId: 888 } }]);
|
||||
await flush();
|
||||
assert.deepEqual(h.calls.map(call => call.message.customerId), ["123", "456"]);
|
||||
assert.deepEqual(plain(h.app.productsContentState.workloadCustomerIds), ["123", "456"]);
|
||||
assert.equal(h.tokens(), 1);
|
||||
assert.equal(h.calls[0].message.authorization.value, "wwb");
|
||||
assert.equal(h.calls[0].message.cacheMaxAgeMs, 12 * 3600000);
|
||||
assert.equal(h.calls[0].message.principal, "user@example.invalid");
|
||||
const original = { RevnId: 11, Name: "Original product" };
|
||||
function products() {
|
||||
h.progress(id, "A", [original, { RevnId: "999" }]);
|
||||
h.progress(id, "B", [{ RevnId: "12" }], true);
|
||||
h.progress(id, "C", [{ RevnId: 11 }, { RevnId: 22 }]);
|
||||
}
|
||||
if (productsFirst) products();
|
||||
h.calls[0].resolve({ ok: true, ...page([11, 12]), cachedAt: 100, fromCache: true, debugRequests: [{ name: "requestWorkloads" }] });
|
||||
h.calls[1].resolve({ ok: true, ...page([22]), cachedAt: 200 });
|
||||
await flush();
|
||||
if (!productsFirst) products();
|
||||
const results = h.app.productsContentState.opptyProductsByNumber;
|
||||
assert.equal(results.get("A").items[0].workload.id, 11);
|
||||
assert.equal(results.get("A").items[0].Name, "Original product");
|
||||
assert.equal(original.workload, undefined, "do not mutate raw product responses or their cache");
|
||||
assert.equal(results.get("A").items[1].workload, null);
|
||||
assert.equal(results.get("B").items[0].workload.id, 12);
|
||||
assert.equal(results.get("B").fromCache, true);
|
||||
assert.equal(results.get("C").items[0].workload, null, "never join a workload from another account");
|
||||
assert.equal(results.get("C").items[1].workload.id, 22);
|
||||
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").fromCache, true);
|
||||
assert.equal(h.logs.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("workload queue limits concurrency to four and ignores a superseded Reference", async () => {
|
||||
const h = content();
|
||||
h.start(Array.from({ length: 9 }, (_, i) => opportunity(`A${i}`, i + 1)));
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 4);
|
||||
h.app.opportunitiesQueryState.periodRequestVersion++;
|
||||
const id = h.start([opportunity("NEW", 100)]);
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 5);
|
||||
for (const call of h.calls.slice(0, 4)) call.resolve({ ok: true, ...page([11]) });
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 5, "do not continue accounts from the old Reference");
|
||||
assert.deepEqual([...h.app.productsContentState.workloadsByCustomer.keys()], ["100"]);
|
||||
h.calls[4].resolve({ ok: true, ...page([22]) });
|
||||
await flush();
|
||||
h.progress(id, "NEW", [{ RevnId: 22 }]);
|
||||
assert.equal(h.app.productsContentState.opptyProductsByNumber.get("NEW").items[0].workload.id, 22);
|
||||
});
|
||||
|
||||
test("one failed account does not block other accounts or discard products", async () => {
|
||||
const h = content();
|
||||
const id = h.start([opportunity("A", 123), opportunity("B", 456)]);
|
||||
await flush();
|
||||
h.progress(id, "A", [{ RevnId: 11 }]);
|
||||
h.calls[0].resolve({ ok: false, error: "Synthetic failure", debugRequests: [{ name: "requestWorkloads", httpStatus: 403 }] });
|
||||
h.calls[1].resolve({ ok: true, ...page([22]) });
|
||||
await flush();
|
||||
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").status, "error");
|
||||
assert.equal(h.app.productsContentState.workloadsByCustomer.get("456").status, "ready");
|
||||
assert.equal(h.app.productsContentState.opptyProductsByNumber.get("A").items.length, 1);
|
||||
assert.equal(h.logs[0].httpStatus, 403);
|
||||
});
|
||||
|
||||
test("no accounts skips authentication, and failed authentication settles workload states", async () => {
|
||||
const h = content();
|
||||
h.start([]);
|
||||
await flush();
|
||||
assert.equal(h.tokens(), 0);
|
||||
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => { throw new Error("Session unavailable"); };
|
||||
h.start([opportunity("A", 123)]);
|
||||
await flush();
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").error, "Session unavailable");
|
||||
});
|
||||
Reference in New Issue
Block a user