Implemented the customer installed-base view
This commit is contained in:
15
README.md
15
README.md
@@ -31,12 +31,23 @@ Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
|||||||
Chromium service worker and Firefox background scripts. The build composes classic
|
Chromium service worker and Firefox background scripts. The build composes classic
|
||||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||||
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||||
`src/features/debug/styles.css` and `src/features/opportunities/styles.css` at
|
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
|
||||||
|
`src/features/install-base/styles.css` at
|
||||||
build time. There is no
|
build time. There is no
|
||||||
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||||
|
|
||||||
## Tests and Maintenance
|
## Tests and Maintenance
|
||||||
|
|
||||||
|
The Installed Base action sits beside customer consumption below Customer. It
|
||||||
|
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
|
||||||
|
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
|
||||||
|
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
|
||||||
|
is false. Missing credentials, invalid collections and stalled pagination show
|
||||||
|
an error rather than partial results. These requests appear in debug; credential
|
||||||
|
headers in this flow are masked before delivery to the content script. No new
|
||||||
|
cache, permissions or session recovery logic is introduced. Amounts use two
|
||||||
|
decimal places without inferring a currency from the separate LC fields.
|
||||||
|
|
||||||
The opportunities table places product counts below Name and the customer
|
The opportunities table places product counts below Name and the customer
|
||||||
consumption action below Customer, without a separate Actions column. Compact
|
consumption action below Customer, without a separate Actions column. Compact
|
||||||
columns retain their values; Name and Customer share the remaining width while
|
columns retain their values; Name and Customer share the remaining width while
|
||||||
@@ -54,7 +65,7 @@ duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
|||||||
mask authorization and sensitive response headers before messaging the source
|
mask authorization and sensitive response headers before messaging the source
|
||||||
tab/frame; this change adds no timeout, retry or authentication behavior.
|
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||||
|
|
||||||
Debug tabs group Authentication, Opportunities, Products, AI and Consumption
|
Debug tabs group Authentication, Opportunities, Products, AI, Consumption and Installed Base
|
||||||
requests; unknown request types appear under Other. Only categories with entries
|
requests; unknown request types appear under Other. Only categories with entries
|
||||||
are shown. All preserves the complete timeline. Every tab sorts by request start
|
are shown. All preserves the complete timeline. Every tab sorts by request start
|
||||||
time, retaining original request IDs and showing start times in collapsed rows.
|
time, retaining original request IDs and showing start times in collapsed rows.
|
||||||
|
|||||||
85
dist/chromium/background.js
vendored
85
dist/chromium/background.js
vendored
@@ -11,6 +11,7 @@ function createMessageTypes() {
|
|||||||
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,8 @@ function createAuthConsumerSession({
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorizations: {
|
authorizations: {
|
||||||
accountSummary: accountSummaryAuthorization,
|
accountSummary: accountSummaryAuthorization,
|
||||||
accountPlans: accountPlansAuthorization
|
accountPlans: accountPlansAuthorization,
|
||||||
|
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||||
},
|
},
|
||||||
debugRequests
|
debugRequests
|
||||||
};
|
};
|
||||||
@@ -438,6 +440,68 @@ function createSpaAccounts({
|
|||||||
return { requestAccountPlans };
|
return { requestAccountPlans };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/install-base.js
|
||||||
|
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
|
||||||
|
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
|
||||||
|
async function requestInstallBase(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const partyNumber = String(message.partyNumber || "").trim();
|
||||||
|
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||||
|
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
|
||||||
|
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||||
|
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||||
|
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
|
||||||
|
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
|
||||||
|
body: JSON.stringify([{ key: "regId", value: partyNumber }])
|
||||||
|
}, "requestAccountSummary", debugRequests);
|
||||||
|
const account = findNestedObject(summary, value =>
|
||||||
|
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
|
||||||
|
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
|
||||||
|
const payload = {
|
||||||
|
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
|
||||||
|
installStatus: ["active", "inactive", "unsupported"], productIds: null,
|
||||||
|
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
|
||||||
|
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
|
||||||
|
};
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
// Bound malformed pagination without silently returning a partial result.
|
||||||
|
for (let page = 0; page < 1000; page += 1) {
|
||||||
|
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestInstallBase", debugRequests);
|
||||||
|
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|
||||||
|
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
||||||
|
throw new Error("requestInstallBase returned an invalid collection.");
|
||||||
|
}
|
||||||
|
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
|
||||||
|
items.push(...result.items);
|
||||||
|
if (!result.hasMore) return { ok: true, items, debugRequests };
|
||||||
|
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
|
||||||
|
offset += result.items.length;
|
||||||
|
}
|
||||||
|
throw new Error("requestInstallBase pagination limit reached.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// The provider chooses the credential header name; never rely on its spelling for redaction.
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
for (const key of Object.keys(entry.requestHeaders || {})) {
|
||||||
|
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInstallBase };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/consumption.js
|
// Source: src/services/spa/consumption.js
|
||||||
function createSpaConsumption({
|
function createSpaConsumption({
|
||||||
CONSUMPTION_TREND_REQUESTS,
|
CONSUMPTION_TREND_REQUESTS,
|
||||||
@@ -1448,6 +1512,7 @@ function registerBackgroundMessages({
|
|||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
|
requestInstallBase,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
@@ -1527,6 +1592,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestInstallBase) {
|
||||||
|
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load installed base.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestAccountPlans) {
|
if (message.type === messageTypes.requestAccountPlans) {
|
||||||
requestAccountPlans(message)
|
requestAccountPlans(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
@@ -1596,6 +1669,12 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
});
|
});
|
||||||
|
const spaInstallBase = createSpaInstallBase({
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
const spaConsumption = createSpaConsumption({
|
const spaConsumption = createSpaConsumption({
|
||||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||||
@@ -1659,12 +1738,13 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
||||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -1672,6 +1752,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
authConsumerSession,
|
authConsumerSession,
|
||||||
authSpaCredentials,
|
authSpaCredentials,
|
||||||
spaAccounts,
|
spaAccounts,
|
||||||
|
spaInstallBase,
|
||||||
spaConsumption,
|
spaConsumption,
|
||||||
spaResponseShapes,
|
spaResponseShapes,
|
||||||
httpBackgroundJson,
|
httpBackgroundJson,
|
||||||
|
|||||||
216
dist/chromium/content.js
vendored
216
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
85
dist/firefox/background.js
vendored
85
dist/firefox/background.js
vendored
@@ -11,6 +11,7 @@ function createMessageTypes() {
|
|||||||
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,8 @@ function createAuthConsumerSession({
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorizations: {
|
authorizations: {
|
||||||
accountSummary: accountSummaryAuthorization,
|
accountSummary: accountSummaryAuthorization,
|
||||||
accountPlans: accountPlansAuthorization
|
accountPlans: accountPlansAuthorization,
|
||||||
|
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||||
},
|
},
|
||||||
debugRequests
|
debugRequests
|
||||||
};
|
};
|
||||||
@@ -438,6 +440,68 @@ function createSpaAccounts({
|
|||||||
return { requestAccountPlans };
|
return { requestAccountPlans };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/install-base.js
|
||||||
|
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
|
||||||
|
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
|
||||||
|
async function requestInstallBase(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const partyNumber = String(message.partyNumber || "").trim();
|
||||||
|
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||||
|
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
|
||||||
|
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||||
|
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||||
|
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
|
||||||
|
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
|
||||||
|
body: JSON.stringify([{ key: "regId", value: partyNumber }])
|
||||||
|
}, "requestAccountSummary", debugRequests);
|
||||||
|
const account = findNestedObject(summary, value =>
|
||||||
|
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
|
||||||
|
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
|
||||||
|
const payload = {
|
||||||
|
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
|
||||||
|
installStatus: ["active", "inactive", "unsupported"], productIds: null,
|
||||||
|
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
|
||||||
|
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
|
||||||
|
};
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
// Bound malformed pagination without silently returning a partial result.
|
||||||
|
for (let page = 0; page < 1000; page += 1) {
|
||||||
|
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestInstallBase", debugRequests);
|
||||||
|
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|
||||||
|
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
||||||
|
throw new Error("requestInstallBase returned an invalid collection.");
|
||||||
|
}
|
||||||
|
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
|
||||||
|
items.push(...result.items);
|
||||||
|
if (!result.hasMore) return { ok: true, items, debugRequests };
|
||||||
|
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
|
||||||
|
offset += result.items.length;
|
||||||
|
}
|
||||||
|
throw new Error("requestInstallBase pagination limit reached.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// The provider chooses the credential header name; never rely on its spelling for redaction.
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
for (const key of Object.keys(entry.requestHeaders || {})) {
|
||||||
|
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInstallBase };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/consumption.js
|
// Source: src/services/spa/consumption.js
|
||||||
function createSpaConsumption({
|
function createSpaConsumption({
|
||||||
CONSUMPTION_TREND_REQUESTS,
|
CONSUMPTION_TREND_REQUESTS,
|
||||||
@@ -1448,6 +1512,7 @@ function registerBackgroundMessages({
|
|||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
|
requestInstallBase,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
@@ -1527,6 +1592,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestInstallBase) {
|
||||||
|
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load installed base.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestAccountPlans) {
|
if (message.type === messageTypes.requestAccountPlans) {
|
||||||
requestAccountPlans(message)
|
requestAccountPlans(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
@@ -1596,6 +1669,12 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
});
|
});
|
||||||
|
const spaInstallBase = createSpaInstallBase({
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
const spaConsumption = createSpaConsumption({
|
const spaConsumption = createSpaConsumption({
|
||||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||||
@@ -1659,12 +1738,13 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
||||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -1672,6 +1752,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
authConsumerSession,
|
authConsumerSession,
|
||||||
authSpaCredentials,
|
authSpaCredentials,
|
||||||
spaAccounts,
|
spaAccounts,
|
||||||
|
spaInstallBase,
|
||||||
spaConsumption,
|
spaConsumption,
|
||||||
spaResponseShapes,
|
spaResponseShapes,
|
||||||
httpBackgroundJson,
|
httpBackgroundJson,
|
||||||
|
|||||||
216
dist/firefox/content.js
vendored
216
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return `// Source: ${file}\n${source}`;
|
return `// Source: ${file}\n${source}`;
|
||||||
});
|
});
|
||||||
if (target === "content") {
|
if (target === "content") {
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css"]
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
|
||||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ module.exports = {
|
|||||||
"src/features/products/details.js",
|
"src/features/products/details.js",
|
||||||
"src/features/ai-summary/content.js",
|
"src/features/ai-summary/content.js",
|
||||||
"src/features/consumption/plans-action.js",
|
"src/features/consumption/plans-action.js",
|
||||||
|
"src/features/install-base/action.js",
|
||||||
|
"src/features/install-base/view.js",
|
||||||
"src/features/consumption/plans-view.js",
|
"src/features/consumption/plans-view.js",
|
||||||
"src/features/consumption/trend-controller.js",
|
"src/features/consumption/trend-controller.js",
|
||||||
"src/features/consumption/chart.js",
|
"src/features/consumption/chart.js",
|
||||||
@@ -56,6 +58,7 @@ module.exports = {
|
|||||||
"src/auth/consumer-session.js",
|
"src/auth/consumer-session.js",
|
||||||
"src/auth/spa-credentials.js",
|
"src/auth/spa-credentials.js",
|
||||||
"src/services/spa/accounts.js",
|
"src/services/spa/accounts.js",
|
||||||
|
"src/services/spa/install-base.js",
|
||||||
"src/services/spa/consumption.js",
|
"src/services/spa/consumption.js",
|
||||||
"src/services/spa/response-shapes.js",
|
"src/services/spa/response-shapes.js",
|
||||||
"src/services/http/background-json.js",
|
"src/services/http/background-json.js",
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ function createAuthConsumerSession({
|
|||||||
ok: true,
|
ok: true,
|
||||||
authorizations: {
|
authorizations: {
|
||||||
accountSummary: accountSummaryAuthorization,
|
accountSummary: accountSummaryAuthorization,
|
||||||
accountPlans: accountPlansAuthorization
|
accountPlans: accountPlansAuthorization,
|
||||||
|
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||||
},
|
},
|
||||||
debugRequests
|
debugRequests
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
});
|
});
|
||||||
|
const spaInstallBase = createSpaInstallBase({
|
||||||
|
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||||
|
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||||
|
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||||
|
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||||
|
});
|
||||||
const spaConsumption = createSpaConsumption({
|
const spaConsumption = createSpaConsumption({
|
||||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||||
@@ -97,12 +103,13 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
|
||||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||||
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||||
const runtimeApi = environment.browser || environment.chrome;
|
const runtimeApi = environment.browser || environment.chrome;
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
@@ -110,6 +117,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
authConsumerSession,
|
authConsumerSession,
|
||||||
authSpaCredentials,
|
authSpaCredentials,
|
||||||
spaAccounts,
|
spaAccounts,
|
||||||
|
spaInstallBase,
|
||||||
spaConsumption,
|
spaConsumption,
|
||||||
spaResponseShapes,
|
spaResponseShapes,
|
||||||
httpBackgroundJson,
|
httpBackgroundJson,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ function registerBackgroundMessages({
|
|||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
|
requestInstallBase,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
requestOpptyProducts,
|
requestOpptyProducts,
|
||||||
@@ -83,6 +84,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.requestInstallBase) {
|
||||||
|
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, error: error.message || "Unable to load installed base.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestAccountPlans) {
|
if (message.type === messageTypes.requestAccountPlans) {
|
||||||
requestAccountPlans(message)
|
requestAccountPlans(message)
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
applyTheme: (...args) => contentTheme.applyTheme(...args),
|
applyTheme: (...args) => contentTheme.applyTheme(...args),
|
||||||
authSessionState,
|
authSessionState,
|
||||||
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||||
|
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||||
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
@@ -197,6 +198,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
|
||||||
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
|
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
|
||||||
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||||
|
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||||
@@ -317,6 +319,20 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
|
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const installBaseView = createInstallBaseView({
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const installBaseAction = createInstallBaseAction({
|
||||||
|
messageTypes,
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
|
||||||
|
environment
|
||||||
|
});
|
||||||
const consumptionPlansAction = createConsumptionPlansAction({
|
const consumptionPlansAction = createConsumptionPlansAction({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
@@ -425,6 +441,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsDetails,
|
productsDetails,
|
||||||
aiSummaryContent,
|
aiSummaryContent,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
|
installBaseAction,
|
||||||
|
installBaseView,
|
||||||
consumptionPlansView,
|
consumptionPlansView,
|
||||||
consumptionTrendController,
|
consumptionTrendController,
|
||||||
consumptionChart,
|
consumptionChart,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function createContentModal({
|
|||||||
applyTheme,
|
applyTheme,
|
||||||
authSessionState,
|
authSessionState,
|
||||||
closeAccountPlansModal,
|
closeAccountPlansModal,
|
||||||
|
closeInstallBaseModal,
|
||||||
closeCustomerFilterPanel,
|
closeCustomerFilterPanel,
|
||||||
closeOwnerFilterPanel,
|
closeOwnerFilterPanel,
|
||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
@@ -432,6 +433,7 @@ function createContentModal({
|
|||||||
if (modal) {
|
if (modal) {
|
||||||
closePreferencesModal();
|
closePreferencesModal();
|
||||||
closeAccountPlansModal();
|
closeAccountPlansModal();
|
||||||
|
closeInstallBaseModal();
|
||||||
modal.hidden = true;
|
modal.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +462,10 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (closeInstallBaseModal()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||||
|
|
||||||
if (accountPlansModal && !accountPlansModal.hidden) {
|
if (accountPlansModal && !accountPlansModal.hidden) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function createDebugContentView({
|
|||||||
const categories = [
|
const categories = [
|
||||||
["all", "All"], ["authentication", "Authentication"],
|
["all", "All"], ["authentication", "Authentication"],
|
||||||
["opportunities", "Opportunities"], ["products", "Products"],
|
["opportunities", "Opportunities"], ["products", "Products"],
|
||||||
["ai", "AI"], ["consumption", "Consumption"], ["other", "Other"]
|
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["other", "Other"]
|
||||||
];
|
];
|
||||||
let selectedCategory = "all";
|
let selectedCategory = "all";
|
||||||
const expandedIds = new Set();
|
const expandedIds = new Set();
|
||||||
@@ -21,6 +21,7 @@ function createDebugContentView({
|
|||||||
if (entry.name === "requestOpportunities") return "opportunities";
|
if (entry.name === "requestOpportunities") return "opportunities";
|
||||||
if (entry.name === "requestOpptyProducts") return "products";
|
if (entry.name === "requestOpptyProducts") return "products";
|
||||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||||
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/features/install-base/action.js
Normal file
53
src/features/install-base/action.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
|
appendBackgroundRequestLog, openInstallBaseModal, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
async function load(partyNumber) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
const response = await sendRuntimeMessage({
|
||||||
|
type: messageTypes.requestInstallBase, partyNumber,
|
||||||
|
authorizations: { accountSummary: authorizations.accountSummary, installBase: authorizations.installBase }
|
||||||
|
});
|
||||||
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
|
if (!response?.ok) throw new Error(response?.error || "Unable to load installed base.");
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInstallBaseButton(opportunity) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-action-button";
|
||||||
|
button.title = "View customer installed base";
|
||||||
|
button.setAttribute("aria-label", `View installed base for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
|
||||||
|
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
icon.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.setAttribute("focusable", "false");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
path.setAttribute("d", "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M11 6.5h6 M11 17.5h6 M8 10v4 M16 10v4");
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-width", "1.7");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
icon.append(path);
|
||||||
|
button.append(icon);
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const view = openInstallBaseModal(opportunity, button);
|
||||||
|
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
|
||||||
|
try {
|
||||||
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
|
if (!pending.has(partyNumber)) {
|
||||||
|
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
|
||||||
|
pending.set(partyNumber, request);
|
||||||
|
}
|
||||||
|
view.render(await pending.get(partyNumber));
|
||||||
|
} catch (error) {
|
||||||
|
view.render([], error.message || "Unable to load installed base.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
return { createInstallBaseButton };
|
||||||
|
}
|
||||||
43
src/features/install-base/styles.css
Normal file
43
src/features/install-base/styles.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog {
|
||||||
|
width: min(1700px, 100%);
|
||||||
|
}
|
||||||
|
.opportunities-extension-install-base-content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.opportunities-extension-install-base-message { margin: 16px; font-size: 13px; }
|
||||||
|
.opportunities-extension-install-base-table {
|
||||||
|
width: 100%; min-width: 1400px; table-layout: fixed; border-collapse: collapse; font-size: 12px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-install-base-table col { width: 7%; }
|
||||||
|
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
|
||||||
|
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
|
||||||
|
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
|
||||||
|
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
|
||||||
|
.opportunities-extension-install-base-table .install-base-col-quantityType { width: 8%; }
|
||||||
|
.opportunities-extension-install-base-table th,
|
||||||
|
.opportunities-extension-install-base-table td {
|
||||||
|
padding: 12px 8px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
|
||||||
|
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.opportunities-extension-install-base-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
|
||||||
|
.opportunities-extension-install-base-table td[data-format="amount"],
|
||||||
|
.opportunities-extension-install-base-table td[data-format="quantity"] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.opportunities-extension-install-base-table td[data-format="date"] { white-space: nowrap; }
|
||||||
|
.opportunities-extension-install-base-status {
|
||||||
|
display: inline-block; padding: 3px 6px; border-radius: 12px; font-size: 10px; font-weight: 600;
|
||||||
|
color: #5f5a55; background: #eceae8; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
|
||||||
|
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status { color: #dedbd7; background: #484541; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #b8e4c6; background: #214d34; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #ffdfa2; background: #614418; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
|
||||||
|
}
|
||||||
126
src/features/install-base/view.js
Normal file
126
src/features/install-base/view.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
const columns = [
|
||||||
|
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
|
||||||
|
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
|
||||||
|
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
|
||||||
|
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
|
||||||
|
["supportEndDate", "Support End Date", "date"], ["licenseValue", "License Value", "amount"],
|
||||||
|
["shipDate", "Ship Date", "date"]
|
||||||
|
];
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function closeInstallBaseModal() {
|
||||||
|
if (!active) return false;
|
||||||
|
const previous = active;
|
||||||
|
active = null;
|
||||||
|
previous.modal.remove();
|
||||||
|
if (previous.trigger?.isConnected) previous.trigger.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createTable(items, loading = false) {
|
||||||
|
const table = element("table", "opportunities-extension-install-base-table");
|
||||||
|
table.setAttribute("aria-label", "Installed base items");
|
||||||
|
const head = element("thead");
|
||||||
|
const header = element("tr");
|
||||||
|
const group = element("colgroup");
|
||||||
|
columns.forEach(([key, label]) => {
|
||||||
|
const col = element("col");
|
||||||
|
col.className = `install-base-col-${key}`;
|
||||||
|
group.append(col);
|
||||||
|
const th = element("th", "", label);
|
||||||
|
th.scope = "col";
|
||||||
|
header.append(th);
|
||||||
|
});
|
||||||
|
head.append(header);
|
||||||
|
const body = element("tbody");
|
||||||
|
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
|
||||||
|
const row = element("tr");
|
||||||
|
columns.forEach(([key, , type]) => {
|
||||||
|
const cell = element("td");
|
||||||
|
if (loading) {
|
||||||
|
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
|
||||||
|
bar.setAttribute("aria-hidden", "true");
|
||||||
|
cell.append(bar);
|
||||||
|
} else {
|
||||||
|
const value = item[key];
|
||||||
|
const missing = value === null || value === undefined || value === "";
|
||||||
|
let text = missing ? "-" : String(value).trim();
|
||||||
|
if (!missing && type === "date") text = formatOracleResponseDate(value);
|
||||||
|
if (!missing && (type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
|
||||||
|
text = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
|
||||||
|
}).format(Number(value));
|
||||||
|
}
|
||||||
|
if (type === "status") {
|
||||||
|
const badge = element("span", "opportunities-extension-install-base-status", text);
|
||||||
|
badge.dataset.status = text.toUpperCase();
|
||||||
|
cell.append(badge);
|
||||||
|
} else cell.textContent = text;
|
||||||
|
if (type) cell.dataset.format = type;
|
||||||
|
}
|
||||||
|
row.append(cell);
|
||||||
|
});
|
||||||
|
body.append(row);
|
||||||
|
});
|
||||||
|
table.append(group, head, body);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
function openInstallBaseModal(opportunity, trigger) {
|
||||||
|
closeInstallBaseModal();
|
||||||
|
const overlay = document.getElementById(MODAL_ID);
|
||||||
|
if (!overlay || overlay.hidden) return { render() {} };
|
||||||
|
const modal = element("section", "opportunities-extension-account-plans-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-busy", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-install-base-title");
|
||||||
|
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-install-base-dialog");
|
||||||
|
const header = element("header", "opportunities-extension-account-plans-header");
|
||||||
|
const heading = element("div");
|
||||||
|
const title = element("h2", "", "Installed Base");
|
||||||
|
title.id = "opportunities-extension-install-base-title";
|
||||||
|
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
|
||||||
|
const close = element("button", "opportunities-extension-icon-button");
|
||||||
|
close.type = "button";
|
||||||
|
close.title = "Close";
|
||||||
|
close.setAttribute("aria-label", "Close installed base");
|
||||||
|
close.append(createCloseIcon());
|
||||||
|
close.addEventListener("click", closeInstallBaseModal);
|
||||||
|
header.append(heading, close);
|
||||||
|
const content = element("div", "opportunities-extension-install-base-content");
|
||||||
|
content.tabIndex = 0;
|
||||||
|
content.setAttribute("role", "region");
|
||||||
|
content.setAttribute("aria-label", "Installed base results");
|
||||||
|
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
content.append(status, createTable([], true));
|
||||||
|
dialog.append(header, content);
|
||||||
|
modal.append(dialog);
|
||||||
|
overlay.append(modal);
|
||||||
|
active = { modal, trigger };
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
event.preventDefault();
|
||||||
|
(document.activeElement === close ? content : close).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
close.focus();
|
||||||
|
return { render(items, error = "") {
|
||||||
|
// A closed or replaced dialog must never receive a late response.
|
||||||
|
if (active?.modal !== modal || overlay.hidden) return;
|
||||||
|
modal.setAttribute("aria-busy", "false");
|
||||||
|
content.textContent = "";
|
||||||
|
status.textContent = error || (items.length ? `${items.length} installed base items` : "No installed base items found for this customer.");
|
||||||
|
status.setAttribute("role", error ? "alert" : "status");
|
||||||
|
content.append(status);
|
||||||
|
if (!error && items.length) content.append(createTable(items));
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
return { openInstallBaseModal, closeInstallBaseModal, createTable };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ function createOpportunitiesTable({
|
|||||||
OPPORTUNITIES_TABLE_ID,
|
OPPORTUNITIES_TABLE_ID,
|
||||||
OPPORTUNITY_DETAIL_URL,
|
OPPORTUNITY_DETAIL_URL,
|
||||||
createCloudConsumptionButton,
|
createCloudConsumptionButton,
|
||||||
|
createInstallBaseButton,
|
||||||
createCopyOpportunityButton,
|
createCopyOpportunityButton,
|
||||||
createDetailLink,
|
createDetailLink,
|
||||||
createProductsCountButton,
|
createProductsCountButton,
|
||||||
@@ -148,7 +149,7 @@ function createOpportunitiesTable({
|
|||||||
);
|
);
|
||||||
const customerActions = document.createElement("span");
|
const customerActions = document.createElement("span");
|
||||||
customerActions.className = "opportunities-extension-cell-actions";
|
customerActions.className = "opportunities-extension-cell-actions";
|
||||||
customerActions.append(createCloudConsumptionButton(item));
|
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item));
|
||||||
customerCellContent.append(customerLink, customerActions);
|
customerCellContent.append(customerLink, customerActions);
|
||||||
cell.append(customerCellContent);
|
cell.append(customerCellContent);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
60
src/services/spa/install-base.js
Normal file
60
src/services/spa/install-base.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
|
||||||
|
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
|
||||||
|
async function requestInstallBase(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const partyNumber = String(message.partyNumber || "").trim();
|
||||||
|
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||||
|
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
|
||||||
|
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||||
|
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||||
|
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
|
||||||
|
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
|
||||||
|
body: JSON.stringify([{ key: "regId", value: partyNumber }])
|
||||||
|
}, "requestAccountSummary", debugRequests);
|
||||||
|
const account = findNestedObject(summary, value =>
|
||||||
|
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
|
||||||
|
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
|
||||||
|
const payload = {
|
||||||
|
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
|
||||||
|
installStatus: ["active", "inactive", "unsupported"], productIds: null,
|
||||||
|
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
|
||||||
|
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
|
||||||
|
};
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
// Bound malformed pagination without silently returning a partial result.
|
||||||
|
for (let page = 0; page < 1000; page += 1) {
|
||||||
|
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestInstallBase", debugRequests);
|
||||||
|
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|
||||||
|
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
||||||
|
throw new Error("requestInstallBase returned an invalid collection.");
|
||||||
|
}
|
||||||
|
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
|
||||||
|
items.push(...result.items);
|
||||||
|
if (!result.hasMore) return { ok: true, items, debugRequests };
|
||||||
|
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
|
||||||
|
offset += result.items.length;
|
||||||
|
}
|
||||||
|
throw new Error("requestInstallBase pagination limit reached.");
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// The provider chooses the credential header name; never rely on its spelling for redaction.
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
for (const key of Object.keys(entry.requestHeaders || {})) {
|
||||||
|
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInstallBase };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ function createMessageTypes() {
|
|||||||
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
|
|||||||
|
|
||||||
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css"]
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
|
||||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||||
|
|||||||
159
tests/installBase.test.cjs
Normal file
159
tests/installBase.test.cjs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response, plain } = require("./support/characterization.cjs");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const authorizations = {
|
||||||
|
accountSummary: { name: "Account-Ti-Authorization", value: "synthetic-summary" },
|
||||||
|
installBase: { name: "synthetic-provider-header", value: "synthetic-installed" }
|
||||||
|
};
|
||||||
|
const message = { type: "opportunitiesExtension.requestInstallBase", partyNumber: "customer-123", authorizations };
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: summary registryId, SAIB header, payload, pagination and protected diagnostics`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.endsWith("consumerTokenService")) return response([
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { label: "Account-Ti-Authorization", value: "synthetic-summary" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { label: "G2m-Authorization", value: "synthetic-consumption" } },
|
||||||
|
{ tokenService: "SAIB-TS", uiaasHeader: { label: "synthetic-provider-header", value: "synthetic-installed" } }
|
||||||
|
]);
|
||||||
|
if (url.endsWith("accountsummary")) return response({ id: "NOT-THE-REGISTRY", registryId: "registry/test" });
|
||||||
|
const offset = Number(new URL(url).searchParams.get("offset"));
|
||||||
|
return response({ items: [{ productName: `Product ${offset}` }], offset, limit: 25, hasMore: offset === 0 });
|
||||||
|
} });
|
||||||
|
const tokens = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
assert.deepEqual(tokens.authorizations.installBase, { ...authorizations.installBase, expires: "" });
|
||||||
|
const result = await h.dispatch({ ...message, authorizations: tokens.authorizations });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.items.length, 2);
|
||||||
|
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "customer-123" }]);
|
||||||
|
assert.equal(calls[1].options.headers[authorizations.accountSummary.name], "synthetic-summary");
|
||||||
|
for (const [index, call] of calls.slice(2).entries()) {
|
||||||
|
assert.ok(call.url.includes(`/account/registry%2Ftest?orderBy=currentSupport%3Adesc&limit=25&offset=${index}`));
|
||||||
|
assert.equal(call.options.method, "POST");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
assert.equal(call.options.headers[authorizations.installBase.name], "synthetic-installed");
|
||||||
|
assert.deepEqual(JSON.parse(call.options.body), {
|
||||||
|
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
|
||||||
|
installStatus: ["active", "inactive", "unsupported"], productIds: null,
|
||||||
|
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
|
||||||
|
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestInstallBase", "requestInstallBase"]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-installed|synthetic-summary/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing optional SAIB does not break existing consumer token flow", async () => {
|
||||||
|
const h = loadBackground({ fetch: async () => response([
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-summary" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
|
||||||
|
]) });
|
||||||
|
const tokens = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
assert.equal(tokens.ok, true);
|
||||||
|
const result = await h.dispatch({ ...message, authorizations: tokens.authorizations });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /SAIB-TS/);
|
||||||
|
assert.equal(result.debugRequests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid registry, pagination, HTTP, JSON and network failures preserve error/debug envelopes", async () => {
|
||||||
|
for (const kind of ["registry", "stalled", "offset", "collection", "http", "json", "network"]) {
|
||||||
|
const h = loadBackground({ fetch: async url => {
|
||||||
|
if (url.endsWith("accountsummary")) return response(kind === "registry" ? { id: "wrong" } : { registryId: "test" });
|
||||||
|
if (kind === "network") throw new Error("Synthetic network failure");
|
||||||
|
if (kind === "http") return response({}, 403);
|
||||||
|
if (kind === "json") return new Response("<html>fallback</html>");
|
||||||
|
if (kind === "collection") return response({ items: "invalid", hasMore: false });
|
||||||
|
return response({ items: [], offset: kind === "offset" ? 25 : 0, hasMore: true });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(message);
|
||||||
|
assert.equal(result.ok, false, kind);
|
||||||
|
assert.ok(result.error, kind);
|
||||||
|
assert.equal(result.debugRequests.length, kind === "registry" ? 1 : 2);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-installed|synthetic-summary/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
|
||||||
|
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
|
||||||
|
set textContent(value) { this.text = value; this.children = []; }
|
||||||
|
get textContent() { return this.text || ""; }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||||
|
focus() { this.document.activeElement = this; }
|
||||||
|
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
|
||||||
|
get isConnected() { return Boolean(this.parent); }
|
||||||
|
click() { return this.listeners.click(); }
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
|
||||||
|
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
|
||||||
|
const overlay = new Element("main", document);
|
||||||
|
const app = loadApplication("content", { document, Intl, Date });
|
||||||
|
return { app, overlay, document };
|
||||||
|
}
|
||||||
|
const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
|
||||||
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||||
|
|
||||||
|
test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => {
|
||||||
|
const { app, overlay, document } = setup();
|
||||||
|
const first = app.installBaseView.openInstallBaseModal(customer);
|
||||||
|
const modal = overlay.children[0];
|
||||||
|
assert.equal(modal.attributes["aria-busy"], "true");
|
||||||
|
assert.equal(walk(modal).filter(node => node.tag === "th").length, 12);
|
||||||
|
const second = app.installBaseView.openInstallBaseModal(customer);
|
||||||
|
first.render([{ productName: "Stale product" }]);
|
||||||
|
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
|
||||||
|
second.render([{ productName: "<script>unsafe</script>", supportStatus: "ACTIVE", quantity: 0,
|
||||||
|
currentSupport: 1234.56, licenseValue: 0, supportStartDate: "2026-03-12", secret: "not-visible" }]);
|
||||||
|
const current = overlay.children[0];
|
||||||
|
const cells = walk(current).filter(node => node.tag === "td");
|
||||||
|
assert.equal(cells.length, 12);
|
||||||
|
assert.equal(cells[2].textContent, "<script>unsafe</script>");
|
||||||
|
assert.equal(cells[2].children.length, 0);
|
||||||
|
assert.equal(cells[3].textContent, "1,234.56");
|
||||||
|
assert.equal(cells[5].children[0].dataset.status, "ACTIVE");
|
||||||
|
assert.equal(cells[7].textContent, "0");
|
||||||
|
assert.equal(cells[10].textContent, "0.00");
|
||||||
|
assert.ok(cells[8].textContent.includes("2026"));
|
||||||
|
current.listeners.keydown({ key: "Tab", preventDefault() {} });
|
||||||
|
assert.equal(document.activeElement.attributes.role, "region");
|
||||||
|
assert.equal(app.installBaseView.closeInstallBaseModal(), true);
|
||||||
|
second.render([], "Late error");
|
||||||
|
assert.equal(overlay.children.length, 0);
|
||||||
|
assert.equal(app.installBaseView.closeInstallBaseModal(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("action opens before auth, deduplicates in-flight customer queries, and allows retry after failure", async () => {
|
||||||
|
const { app, overlay } = setup();
|
||||||
|
let resolveAuth;
|
||||||
|
let calls = 0;
|
||||||
|
const logs = [];
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = () => new Promise(resolve => { resolveAuth = resolve; });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
calls += 1;
|
||||||
|
assert.equal(message.type, "opportunitiesExtension.requestInstallBase");
|
||||||
|
assert.equal(message.partyNumber, "customer-123");
|
||||||
|
assert.deepEqual(plain(message.authorizations), authorizations);
|
||||||
|
return { ok: false, error: "Synthetic failure", debugRequests: ["debug"] };
|
||||||
|
};
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
|
||||||
|
const button = app.installBaseAction.createInstallBaseButton(customer);
|
||||||
|
const first = button.click();
|
||||||
|
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
|
||||||
|
const second = button.click();
|
||||||
|
resolveAuth(authorizations);
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(logs.length, 1);
|
||||||
|
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Synthetic failure"));
|
||||||
|
const retry = button.click();
|
||||||
|
resolveAuth(authorizations);
|
||||||
|
await retry;
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
@@ -61,6 +61,7 @@ test("ten columns retain their links and put product/customer controls below the
|
|||||||
assert.equal(customer.children[0].textContent, "Synthetic customer");
|
assert.equal(customer.children[0].textContent, "Synthetic customer");
|
||||||
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
|
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
|
||||||
assert.equal(customer.children[1].children[0].title, "View customer consumption");
|
assert.equal(customer.children[1].children[0].title, "View customer consumption");
|
||||||
|
assert.equal(customer.children[1].children[1].title, "View customer installed base");
|
||||||
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
|
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
|
||||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
|
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
|
||||||
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
||||||
@@ -153,6 +154,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
|||||||
assert.equal(fixed + remaining / 2 + remaining / 2, width);
|
assert.equal(fixed + remaining / 2 + remaining / 2, width);
|
||||||
assert.ok(remaining > 0);
|
assert.ok(remaining > 0);
|
||||||
}
|
}
|
||||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/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"].map(read).join("\n");
|
||||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user