Implemented the customer service requests view
This commit is contained in:
18
README.md
18
README.md
@@ -32,12 +32,26 @@ Chromium service worker and Firefox background scripts. The build composes class
|
||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
|
||||
`src/features/install-base/styles.css` at
|
||||
`src/features/install-base/styles.css` and `src/features/service-requests/styles.css` at
|
||||
build time. There is no
|
||||
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||
|
||||
## Tests and Maintenance
|
||||
|
||||
The Service Requests action sits immediately after Installed Base below Customer.
|
||||
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
|
||||
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
|
||||
`id` (not `registryId`) in the GET `requestCustomerServiceRequest` URL. The SR
|
||||
credential is `uiaasHeader.value` from `SPA-ORANGE`, sent verbatim in
|
||||
`custsr-auth`, without a Bearer prefix, regardless of `uiaasHeader.label`.
|
||||
Pages of 40 are consolidated using `totalResults`, `count`
|
||||
and `offset`. Concurrent clicks for the same customer share the in-flight query;
|
||||
results are not cached, and closed/replaced dialogs ignore late responses.
|
||||
The table maps Name/Registry ID from `customer`, Product Name from `component`,
|
||||
and formats status as a badge and dates using the existing date formatter.
|
||||
Debug contains a Service Requests category; credential headers are masked before
|
||||
delivery to content. This feature adds no permissions or authentication retries.
|
||||
|
||||
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
|
||||
@@ -65,7 +79,7 @@ duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
||||
mask authorization and sensitive response headers before messaging the source
|
||||
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||
|
||||
Debug tabs group Authentication, Opportunities, Products, AI, Consumption and Installed Base
|
||||
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
|
||||
requests; unknown request types appear under Other. Only categories with entries
|
||||
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.
|
||||
|
||||
85
dist/chromium/background.js
vendored
85
dist/chromium/background.js
vendored
@@ -12,6 +12,7 @@ function createMessageTypes() {
|
||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||
});
|
||||
}
|
||||
@@ -158,7 +159,8 @@ function createAuthConsumerSession({
|
||||
authorizations: {
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
@@ -502,6 +504,68 @@ function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject
|
||||
return { requestInstallBase };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/customer-service-requests.js
|
||||
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
|
||||
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
|
||||
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
|
||||
|
||||
async function requestCustomerServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const partyNumber = String(message.partyNumber || "").trim();
|
||||
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
|
||||
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.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 accountSummaryId = findAccountPlanId(summary);
|
||||
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
|
||||
throw new Error("Account id was not found in requestAccountSummary.");
|
||||
}
|
||||
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
// This endpoint uses totalResults/count rather than hasMore.
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
|
||||
const result = await fetchJson(url, {
|
||||
method: "GET", credentials: "include",
|
||||
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
|
||||
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
|
||||
}, "requestCustomerServiceRequest", debugRequests);
|
||||
if (!result || !Array.isArray(result.objects)
|
||||
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|
||||
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|
||||
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
|
||||
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
|
||||
}
|
||||
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
|
||||
items.push(...result.objects);
|
||||
offset += result.count;
|
||||
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
|
||||
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
|
||||
}
|
||||
throw new Error("requestCustomerServiceRequest pagination limit reached.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
} finally {
|
||||
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 { requestCustomerServiceRequest };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/consumption.js
|
||||
function createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS,
|
||||
@@ -1513,6 +1577,7 @@ function registerBackgroundMessages({
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestOpptyProducts,
|
||||
@@ -1592,6 +1657,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestCustomerServiceRequest) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestInstallBase) {
|
||||
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load installed base.",
|
||||
@@ -1675,6 +1748,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaConsumption = createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||
@@ -1739,12 +1818,13 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
@@ -1753,6 +1833,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
authSpaCredentials,
|
||||
spaAccounts,
|
||||
spaInstallBase,
|
||||
spaCustomerServiceRequests,
|
||||
spaConsumption,
|
||||
spaResponseShapes,
|
||||
httpBackgroundJson,
|
||||
|
||||
211
dist/chromium/content.js
vendored
211
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
@@ -12,6 +12,7 @@ function createMessageTypes() {
|
||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||
});
|
||||
}
|
||||
@@ -158,7 +159,8 @@ function createAuthConsumerSession({
|
||||
authorizations: {
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
@@ -502,6 +504,68 @@ function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject
|
||||
return { requestInstallBase };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/customer-service-requests.js
|
||||
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
|
||||
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
|
||||
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
|
||||
|
||||
async function requestCustomerServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const partyNumber = String(message.partyNumber || "").trim();
|
||||
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
|
||||
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.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 accountSummaryId = findAccountPlanId(summary);
|
||||
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
|
||||
throw new Error("Account id was not found in requestAccountSummary.");
|
||||
}
|
||||
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
// This endpoint uses totalResults/count rather than hasMore.
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
|
||||
const result = await fetchJson(url, {
|
||||
method: "GET", credentials: "include",
|
||||
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
|
||||
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
|
||||
}, "requestCustomerServiceRequest", debugRequests);
|
||||
if (!result || !Array.isArray(result.objects)
|
||||
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|
||||
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|
||||
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
|
||||
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
|
||||
}
|
||||
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
|
||||
items.push(...result.objects);
|
||||
offset += result.count;
|
||||
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
|
||||
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
|
||||
}
|
||||
throw new Error("requestCustomerServiceRequest pagination limit reached.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
} finally {
|
||||
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 { requestCustomerServiceRequest };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/consumption.js
|
||||
function createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS,
|
||||
@@ -1513,6 +1577,7 @@ function registerBackgroundMessages({
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestOpptyProducts,
|
||||
@@ -1592,6 +1657,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestCustomerServiceRequest) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestInstallBase) {
|
||||
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load installed base.",
|
||||
@@ -1675,6 +1748,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaConsumption = createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||
@@ -1739,12 +1818,13 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
@@ -1753,6 +1833,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
authSpaCredentials,
|
||||
spaAccounts,
|
||||
spaInstallBase,
|
||||
spaCustomerServiceRequests,
|
||||
spaConsumption,
|
||||
spaResponseShapes,
|
||||
httpBackgroundJson,
|
||||
|
||||
211
dist/firefox/content.js
vendored
211
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ different from extension storage and is not a safe place for credentials.
|
||||
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
|
||||
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
|
||||
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
|
||||
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
|
||||
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
|
||||
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
|
||||
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
|
||||
@@ -56,6 +57,7 @@ Production tokens are held in memory, not deliberately persisted by the extensio
|
||||
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
|
||||
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
|
||||
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
|
||||
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
|
||||
|
||||
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
|
||||
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
|
||||
@@ -68,6 +70,7 @@ operation-specific headers rather than unifying all calls under a default token.
|
||||
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
|
||||
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
|
||||
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
|
||||
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
|
||||
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
|
||||
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
|
||||
|
||||
@@ -90,7 +93,8 @@ was silently changed during extraction.
|
||||
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
|
||||
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
|
||||
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
|
||||
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans, debugRequests |
|
||||
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans/installBase/customerServiceRequests, debugRequests |
|
||||
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
|
||||
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
|
||||
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
|
||||
return `// Source: ${file}\n${source}`;
|
||||
});
|
||||
if (target === "content") {
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/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", "src/features/service-requests/styles.css"]
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ module.exports = {
|
||||
"src/features/consumption/plans-action.js",
|
||||
"src/features/install-base/action.js",
|
||||
"src/features/install-base/view.js",
|
||||
"src/features/service-requests/action.js",
|
||||
"src/features/service-requests/view.js",
|
||||
"src/features/consumption/plans-view.js",
|
||||
"src/features/consumption/trend-controller.js",
|
||||
"src/features/consumption/chart.js",
|
||||
@@ -59,6 +61,7 @@ module.exports = {
|
||||
"src/auth/spa-credentials.js",
|
||||
"src/services/spa/accounts.js",
|
||||
"src/services/spa/install-base.js",
|
||||
"src/services/spa/customer-service-requests.js",
|
||||
"src/services/spa/consumption.js",
|
||||
"src/services/spa/response-shapes.js",
|
||||
"src/services/http/background-json.js",
|
||||
|
||||
@@ -59,7 +59,8 @@ function createAuthConsumerSession({
|
||||
authorizations: {
|
||||
accountSummary: accountSummaryAuthorization,
|
||||
accountPlans: accountPlansAuthorization,
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
|
||||
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
|
||||
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
|
||||
},
|
||||
debugRequests
|
||||
};
|
||||
|
||||
@@ -40,6 +40,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
|
||||
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
|
||||
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
|
||||
});
|
||||
const spaConsumption = createSpaConsumption({
|
||||
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
|
||||
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
|
||||
@@ -104,12 +110,13 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
|
||||
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
|
||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
function start() {
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
|
||||
}
|
||||
return {
|
||||
start, contracts,
|
||||
@@ -118,6 +125,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
authSpaCredentials,
|
||||
spaAccounts,
|
||||
spaInstallBase,
|
||||
spaCustomerServiceRequests,
|
||||
spaConsumption,
|
||||
spaResponseShapes,
|
||||
httpBackgroundJson,
|
||||
|
||||
@@ -5,6 +5,7 @@ function registerBackgroundMessages({
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
requestInstallBase,
|
||||
requestCustomerServiceRequest,
|
||||
requestConsumerTokenService,
|
||||
requestConsumptionTrend,
|
||||
requestOpptyProducts,
|
||||
@@ -84,6 +85,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestCustomerServiceRequest) {
|
||||
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load service requests.",
|
||||
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.requestInstallBase) {
|
||||
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
|
||||
ok: false, error: error.message || "Unable to load installed base.",
|
||||
|
||||
@@ -77,6 +77,7 @@ function createContentApplication(overrides = {}) {
|
||||
authSessionState,
|
||||
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
|
||||
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
|
||||
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
|
||||
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
|
||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||
@@ -199,6 +200,7 @@ function createContentApplication(overrides = {}) {
|
||||
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
|
||||
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
|
||||
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
|
||||
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
|
||||
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
|
||||
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
|
||||
@@ -333,6 +335,20 @@ function createContentApplication(overrides = {}) {
|
||||
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
|
||||
environment
|
||||
});
|
||||
const serviceRequestsView = createServiceRequestsView({
|
||||
MODAL_ID: contracts.MODAL_ID,
|
||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||
environment
|
||||
});
|
||||
const serviceRequestsAction = createServiceRequestsAction({
|
||||
messageTypes,
|
||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
openServiceRequestsModal: (...args) => serviceRequestsView.openServiceRequestsModal(...args),
|
||||
environment
|
||||
});
|
||||
const consumptionPlansAction = createConsumptionPlansAction({
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||
@@ -443,6 +459,8 @@ function createContentApplication(overrides = {}) {
|
||||
consumptionPlansAction,
|
||||
installBaseAction,
|
||||
installBaseView,
|
||||
serviceRequestsAction,
|
||||
serviceRequestsView,
|
||||
consumptionPlansView,
|
||||
consumptionTrendController,
|
||||
consumptionChart,
|
||||
|
||||
@@ -27,6 +27,7 @@ function createContentModal({
|
||||
authSessionState,
|
||||
closeAccountPlansModal,
|
||||
closeInstallBaseModal,
|
||||
closeServiceRequestsModal,
|
||||
closeCustomerFilterPanel,
|
||||
closeOwnerFilterPanel,
|
||||
closePreferencesModal,
|
||||
@@ -434,6 +435,7 @@ function createContentModal({
|
||||
closePreferencesModal();
|
||||
closeAccountPlansModal();
|
||||
closeInstallBaseModal();
|
||||
closeServiceRequestsModal();
|
||||
modal.hidden = true;
|
||||
}
|
||||
|
||||
@@ -462,6 +464,10 @@ function createContentModal({
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (closeServiceRequestsModal()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (closeInstallBaseModal()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -11,7 +11,8 @@ function createDebugContentView({
|
||||
const categories = [
|
||||
["all", "All"], ["authentication", "Authentication"],
|
||||
["opportunities", "Opportunities"], ["products", "Products"],
|
||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["other", "Other"]
|
||||
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
|
||||
["serviceRequests", "Service Requests"], ["other", "Other"]
|
||||
];
|
||||
let selectedCategory = "all";
|
||||
const expandedIds = new Set();
|
||||
@@ -22,6 +23,7 @@ function createDebugContentView({
|
||||
if (entry.name === "requestOpptyProducts") return "products";
|
||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||
if (entry.name === "requestInstallBase") return "installBase";
|
||||
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
|
||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -72,11 +72,16 @@
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-action-button {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -5,6 +5,7 @@ function createOpportunitiesTable({
|
||||
OPPORTUNITY_DETAIL_URL,
|
||||
createCloudConsumptionButton,
|
||||
createInstallBaseButton,
|
||||
createServiceRequestsButton,
|
||||
createCopyOpportunityButton,
|
||||
createDetailLink,
|
||||
createProductsCountButton,
|
||||
@@ -149,7 +150,7 @@ function createOpportunitiesTable({
|
||||
);
|
||||
const customerActions = document.createElement("span");
|
||||
customerActions.className = "opportunities-extension-cell-actions";
|
||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item));
|
||||
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item));
|
||||
customerCellContent.append(customerLink, customerActions);
|
||||
cell.append(customerCellContent);
|
||||
} else {
|
||||
|
||||
52
src/features/service-requests/action.js
Normal file
52
src/features/service-requests/action.js
Normal file
@@ -0,0 +1,52 @@
|
||||
function createServiceRequestsAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, openServiceRequestsModal, environment }) {
|
||||
const { document } = environment;
|
||||
const pending = new Map();
|
||||
|
||||
async function load(partyNumber) {
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
const response = await sendRuntimeMessage({
|
||||
type: messageTypes.requestCustomerServiceRequest, partyNumber,
|
||||
authorizations: { accountSummary: authorizations.accountSummary, customerServiceRequests: authorizations.customerServiceRequests }
|
||||
});
|
||||
appendBackgroundRequestLog(response?.debugRequests);
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to load service requests.");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
function createServiceRequestsButton(opportunity) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-action-button";
|
||||
button.title = "View customer service requests";
|
||||
button.setAttribute("aria-label", `View service requests 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 14v-3a8 8 0 0 1 16 0v3 M4 11H3v7h4v-7H4 M20 11h1v7h-4v-7h3 M20 18v1a2 2 0 0 1-2 2h-6");
|
||||
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 = openServiceRequestsModal(opportunity, button);
|
||||
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
|
||||
try {
|
||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||
if (!pending.has(partyNumber)) {
|
||||
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
|
||||
}
|
||||
view.render(await pending.get(partyNumber));
|
||||
} catch (error) {
|
||||
view.render([], error.message || "Unable to load service requests.");
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { createServiceRequestsButton };
|
||||
}
|
||||
34
src/features/service-requests/styles.css
Normal file
34
src/features/service-requests/styles.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.opportunities-extension-account-plans-dialog.opportunities-extension-service-requests-dialog { width: min(1700px, 100%); }
|
||||
.opportunities-extension-service-requests-content { min-width: 0; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
|
||||
.opportunities-extension-service-requests-message { margin: 16px; font-size: 13px; }
|
||||
.opportunities-extension-service-requests-table {
|
||||
width: 100%; min-width: 1200px; table-layout: fixed; border-collapse: collapse; font-size: 12px;
|
||||
}
|
||||
.opportunities-extension-service-requests-table col { width: 7%; }
|
||||
.opportunities-extension-service-requests-table .service-requests-col-name { width: 17%; }
|
||||
.opportunities-extension-service-requests-table .service-requests-col-id { width: 10%; }
|
||||
.opportunities-extension-service-requests-table .service-requests-col-severity { width: 5%; }
|
||||
.opportunities-extension-service-requests-table .service-requests-col-subStatus { width: 14%; }
|
||||
.opportunities-extension-service-requests-table .service-requests-col-productName { width: 12%; }
|
||||
.opportunities-extension-service-requests-table th,
|
||||
.opportunities-extension-service-requests-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-service-requests-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
|
||||
.opportunities-extension-service-requests-table td[data-field="created"],
|
||||
.opportunities-extension-service-requests-table td[data-field="lastUpdated"],
|
||||
.opportunities-extension-service-requests-table td[data-field="id"] { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.opportunities-extension-service-request-status {
|
||||
display: inline-block; padding: 3px 6px; border-radius: 12px; font-size: 10px; font-weight: 600;
|
||||
color: #5f5a55; background: #eceae8;
|
||||
}
|
||||
.opportunities-extension-service-request-status[data-status="OPEN"] { color: #17693a; background: #e1f1e5; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th { background: #353331; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th,
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table td { border-color: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-request-status { color: #dedbd7; background: #484541; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-request-status[data-status="OPEN"] { color: #b8e4c6; background: #214d34; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
|
||||
}
|
||||
121
src/features/service-requests/view.js
Normal file
121
src/features/service-requests/view.js
Normal file
@@ -0,0 +1,121 @@
|
||||
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
const columns = [
|
||||
["name", "Name", item => item.customer?.name],
|
||||
["registryId", "Registry ID", item => item.customer?.registryId],
|
||||
["id", "SR Number", item => item.id],
|
||||
["severity", "Severity", item => item.severity],
|
||||
["status", "Status", item => item.status],
|
||||
["subStatus", "Substatus", item => item.subStatus],
|
||||
["productName", "Product Name", item => item.component?.productName],
|
||||
["os", "OS", item => item.os], ["csi", "CSI", item => item.csi],
|
||||
["created", "Creation date", item => item.created],
|
||||
["lastUpdated", "Modified", item => item.lastUpdated]
|
||||
];
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function closeServiceRequestsModal() {
|
||||
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-service-requests-table");
|
||||
table.setAttribute("aria-label", "Customer service requests");
|
||||
const group = element("colgroup");
|
||||
const head = element("thead");
|
||||
const header = element("tr");
|
||||
columns.forEach(([key, label]) => {
|
||||
group.append(element("col", `service-requests-col-${key}`));
|
||||
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, , getValue]) => {
|
||||
const cell = element("td");
|
||||
cell.dataset.field = key;
|
||||
if (loading) {
|
||||
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
|
||||
bar.setAttribute("aria-hidden", "true");
|
||||
cell.append(bar);
|
||||
} else {
|
||||
const value = getValue(item);
|
||||
let text = value === null || value === undefined || value === "" ? "-" : String(value);
|
||||
if (text !== "-" && (key === "created" || key === "lastUpdated")) text = formatOracleResponseDate(value);
|
||||
if (key === "status") {
|
||||
const badge = element("span", "opportunities-extension-service-request-status", text);
|
||||
badge.dataset.status = text.trim().toUpperCase();
|
||||
cell.append(badge);
|
||||
} else cell.textContent = text;
|
||||
}
|
||||
row.append(cell);
|
||||
});
|
||||
body.append(row);
|
||||
});
|
||||
table.append(group, head, body);
|
||||
return table;
|
||||
}
|
||||
function openServiceRequestsModal(opportunity, trigger) {
|
||||
closeServiceRequestsModal();
|
||||
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-service-requests-title");
|
||||
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-service-requests-dialog");
|
||||
const header = element("header", "opportunities-extension-account-plans-header");
|
||||
const heading = element("div");
|
||||
const title = element("h2", "", "Service Requests");
|
||||
title.id = "opportunities-extension-service-requests-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 service requests");
|
||||
close.append(createCloseIcon());
|
||||
close.addEventListener("click", closeServiceRequestsModal);
|
||||
header.append(heading, close);
|
||||
const content = element("div", "opportunities-extension-service-requests-content");
|
||||
content.tabIndex = 0;
|
||||
content.setAttribute("role", "region");
|
||||
content.setAttribute("aria-label", "Service requests results");
|
||||
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
|
||||
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 = "") {
|
||||
if (active?.modal !== modal || overlay.hidden) return;
|
||||
modal.setAttribute("aria-busy", "false");
|
||||
content.textContent = "";
|
||||
status.textContent = error || (items.length ? `${items.length} service requests` : "No open service requests found for this customer.");
|
||||
status.setAttribute("role", error ? "alert" : "status");
|
||||
content.append(status);
|
||||
if (!error && items.length) content.append(createTable(items));
|
||||
} };
|
||||
}
|
||||
return { openServiceRequestsModal, closeServiceRequestsModal, createTable };
|
||||
}
|
||||
60
src/services/spa/customer-service-requests.js
Normal file
60
src/services/spa/customer-service-requests.js
Normal file
@@ -0,0 +1,60 @@
|
||||
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
|
||||
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
|
||||
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
|
||||
|
||||
async function requestCustomerServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const partyNumber = String(message.partyNumber || "").trim();
|
||||
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
|
||||
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
|
||||
if (!partyNumber) throw new Error("Customer Party Number is required.");
|
||||
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
|
||||
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.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 accountSummaryId = findAccountPlanId(summary);
|
||||
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
|
||||
throw new Error("Account id was not found in requestAccountSummary.");
|
||||
}
|
||||
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
// This endpoint uses totalResults/count rather than hasMore.
|
||||
for (let page = 0; page < 1000; page += 1) {
|
||||
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
|
||||
const result = await fetchJson(url, {
|
||||
method: "GET", credentials: "include",
|
||||
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
|
||||
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
|
||||
}, "requestCustomerServiceRequest", debugRequests);
|
||||
if (!result || !Array.isArray(result.objects)
|
||||
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|
||||
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|
||||
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
|
||||
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
|
||||
}
|
||||
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
|
||||
items.push(...result.objects);
|
||||
offset += result.count;
|
||||
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
|
||||
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
|
||||
}
|
||||
throw new Error("requestCustomerServiceRequest pagination limit reached.");
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
} finally {
|
||||
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 { requestCustomerServiceRequest };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ function createMessageTypes() {
|
||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ test("request categories cover every current family and unknown requests stay vi
|
||||
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
|
||||
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
||||
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
||||
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
|
||||
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
|
||||
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
|
||||
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
|
||||
@@ -93,7 +94,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", () => {
|
||||
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"]
|
||||
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"]
|
||||
.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"));
|
||||
|
||||
@@ -62,6 +62,7 @@ test("ten columns retain their links and put product/customer controls below the
|
||||
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[1].title, "View customer installed base");
|
||||
assert.equal(customer.children[1].children[2].title, "View customer service requests");
|
||||
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("customer"), "opportunities-extension-products-count"), undefined);
|
||||
@@ -146,6 +147,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.match(actions, /position: absolute/);
|
||||
assert.match(actions, /bottom: 10px/);
|
||||
assert.match(actions, /height: 24px/);
|
||||
assert.match(css, /min-width: 84px/);
|
||||
assert.match(css, /flex: 0 0 24px/);
|
||||
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
||||
assert.doesNotMatch(css, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
|
||||
@@ -153,7 +156,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
const remaining = width - fixed;
|
||||
assert.equal(fixed + remaining / 2 + remaining / 2, width);
|
||||
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"].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"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
222
tests/serviceRequests.test.cjs
Normal file
222
tests/serviceRequests.test.cjs
Normal file
@@ -0,0 +1,222 @@
|
||||
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 fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const authorizations = {
|
||||
accountSummary: { name: "Account-Ti-Authorization", value: "synthetic-summary-token" },
|
||||
customerServiceRequests: { name: "lead-auth", value: "synthetic-sr-token" }
|
||||
};
|
||||
const message = { type: "opportunitiesExtension.requestCustomerServiceRequest", partyNumber: "customer-test", authorizations };
|
||||
const tokens = [
|
||||
{ tokenService: "ACCP-TS", uiaasHeader: { label: "Account-Ti-Authorization", value: "synthetic-summary-token" } },
|
||||
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption-token" } },
|
||||
{ tokenService: "SAIB-TS", uiaasHeader: { value: "synthetic-install-token" } },
|
||||
{ tokenService: "SPA-ORANGE", uiaasHeader: { label: "lead-auth", value: "synthetic-sr-token" } }
|
||||
];
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: SPA-ORANGE selection, summary id (not registryId), GET and pagination`, async () => {
|
||||
const calls = [];
|
||||
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.endsWith("consumerTokenService")) return response(tokens);
|
||||
if (url.endsWith("accountsummary")) return response({ id: "account/id", registryId: "WRONG-ID" });
|
||||
const offset = Number(new URL(url).searchParams.get("offset"));
|
||||
const count = offset === 0 ? 40 : 1;
|
||||
return response({ totalResults: 41, offset, count, limit: 40,
|
||||
objects: Array.from({ length: count }, (_, i) => ({ id: `SR-${offset + i}` })) });
|
||||
} });
|
||||
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||
assert.deepEqual(auth.authorizations.customerServiceRequests, { ...authorizations.customerServiceRequests, expires: "" });
|
||||
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items.length, 41);
|
||||
assert.equal(calls[1].options.method, "POST");
|
||||
assert.equal(calls[1].options.headers[authorizations.accountSummary.name], authorizations.accountSummary.value);
|
||||
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "customer-test" }]);
|
||||
for (const [index, call] of calls.slice(2).entries()) {
|
||||
const url = new URL(call.url);
|
||||
assert.equal(url.pathname, "/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/account%2Fid");
|
||||
assert.deepEqual(JSON.parse(url.searchParams.get("customerSRFilter")), { hierarchy: "single", status: "open" });
|
||||
assert.equal(url.searchParams.get("orderBy"), "creationDate");
|
||||
assert.equal(url.searchParams.get("orderByMode"), "desc");
|
||||
assert.equal(url.searchParams.get("limit"), "40");
|
||||
assert.equal(url.searchParams.get("offset"), String(index * 40));
|
||||
assert.equal(call.options.method, "GET");
|
||||
assert.equal(call.options.body, undefined);
|
||||
assert.equal(call.options.credentials, "include");
|
||||
assert.deepEqual(plain(call.options.headers), {
|
||||
Accept: "application/json", "custsr-auth": authorizations.customerServiceRequests.value
|
||||
}, "send the raw SPA-ORANGE value in custsr-auth, never lead-auth or Authorization/Bearer");
|
||||
}
|
||||
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestCustomerServiceRequest", "requestCustomerServiceRequest"]);
|
||||
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
|
||||
assert.ok(result.debugRequests.every(entry => entry.httpStatus === 200));
|
||||
assert.equal(result.debugRequests[1].requestHeaders["custsr-auth"], "(present)");
|
||||
});
|
||||
}
|
||||
|
||||
test("SR authentication uses SPA-ORANGE value even when its header label is absent", async () => {
|
||||
let srCalls = 0;
|
||||
const h = loadBackground({ fetch: async (url, options) => {
|
||||
if (url.endsWith("consumerTokenService")) return response(tokens.map(token => token.tokenService === "SPA-ORANGE"
|
||||
? { tokenService: "SPA-ORANGE", uiaasHeader: { value: "synthetic-sr-token" } } : token));
|
||||
if (url.endsWith("accountsummary")) return response({ id: "account-test" });
|
||||
srCalls += 1;
|
||||
assert.deepEqual(plain(options.headers), { Accept: "application/json", "custsr-auth": "synthetic-sr-token" });
|
||||
return response({ totalResults: 0, offset: 0, count: 0, objects: [] });
|
||||
} });
|
||||
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(srCalls, 1);
|
||||
});
|
||||
|
||||
test("zero and exact page-size totals stop without an extra request", async () => {
|
||||
for (const total of [0, 40]) {
|
||||
let calls = 0;
|
||||
const h = loadBackground({ fetch: async url => {
|
||||
calls += 1;
|
||||
if (url.endsWith("accountsummary")) return response([{ id: "summary-id" }]);
|
||||
return response({ totalResults: total, offset: 0, count: total, objects: Array.from({ length: total }, (_, i) => ({ id: `SR-${i}` })) });
|
||||
} });
|
||||
const result = await h.dispatch(message);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items.length, total);
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing SPA-ORANGE stays optional for existing features and fails only the new action", async () => {
|
||||
const h = loadBackground({ fetch: async () => response(tokens.filter(token => token.tokenService !== "SPA-ORANGE")) });
|
||||
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||
assert.equal(auth.ok, true);
|
||||
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /SPA-ORANGE/);
|
||||
assert.equal(result.debugRequests.length, 0);
|
||||
});
|
||||
|
||||
test("missing identifiers, stalled pages, malformed collections, network and HTTP failures are visible", async () => {
|
||||
for (const kind of ["id", "stalled", "offset", "count", "total", "objects", "http", "json", "network"]) {
|
||||
const h = loadBackground({ fetch: async url => {
|
||||
if (url.endsWith("accountsummary")) return response(kind === "id" ? { registryId: "wrong" } : { id: "account-id" });
|
||||
if (kind === "http") return response({}, 401);
|
||||
if (kind === "json") return new Response("<html>fallback</html>");
|
||||
if (kind === "network") throw new Error("Synthetic network failure");
|
||||
const page = { totalResults: 1, offset: 0, count: 0, objects: [] };
|
||||
if (kind === "offset") page.offset = 40;
|
||||
if (kind === "count") page.count = 1;
|
||||
if (kind === "total") delete page.totalResults;
|
||||
if (kind === "objects") page.objects = null;
|
||||
return response(page);
|
||||
} });
|
||||
const result = await h.dispatch(message);
|
||||
assert.equal(result.ok, false, kind);
|
||||
assert.ok(result.error, kind);
|
||||
assert.equal(result.debugRequests.length, kind === "id" ? 1 : 2);
|
||||
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
|
||||
}
|
||||
});
|
||||
|
||||
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(); }
|
||||
}
|
||||
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||
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-test", PartyUniqueName: "Synthetic customer" } };
|
||||
|
||||
test("skeleton, exact field mapping, safe text, dates, badge, empty/error and focus states", () => {
|
||||
const { app, overlay, document } = setup();
|
||||
const trigger = new Element("button", document);
|
||||
overlay.append(trigger);
|
||||
const view = app.serviceRequestsView.openServiceRequestsModal(customer, trigger);
|
||||
const modal = overlay.children[1];
|
||||
assert.equal(modal.attributes["aria-busy"], "true");
|
||||
assert.equal(walk(modal).filter(node => node.tag === "td").length, 88);
|
||||
assert.deepEqual(walk(modal).filter(node => node.tag === "th").map(node => node.textContent), [
|
||||
"Name", "Registry ID", "SR Number", "Severity", "Status", "Substatus", "Product Name", "OS", "CSI", "Creation date", "Modified"
|
||||
]);
|
||||
view.render([{ customer: { name: "<script>not HTML</script>", registryId: "reg-1" }, id: "SR-1", severity: "3", status: "Open",
|
||||
subStatus: "Work In Progress", component: { productName: null }, os: ": ", csi: "Unspecified", created: "2026-03-04", lastUpdated: "2026-09-17" }]);
|
||||
assert.equal(modal.attributes["aria-busy"], "false");
|
||||
const cells = walk(modal).filter(node => node.tag === "td");
|
||||
assert.equal(cells.length, 11);
|
||||
assert.equal(cells[0].textContent, "<script>not HTML</script>");
|
||||
assert.equal(cells[0].children.length, 0);
|
||||
assert.equal(cells[1].textContent, "reg-1");
|
||||
assert.equal(cells[2].textContent, "SR-1");
|
||||
assert.equal(cells[4].children[0].dataset.status, "OPEN");
|
||||
assert.equal(cells[5].textContent, "Work In Progress");
|
||||
assert.equal(cells[6].textContent, "-");
|
||||
assert.equal(cells[9].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-03-04"));
|
||||
assert.equal(cells[10].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-09-17"));
|
||||
modal.listeners.keydown({ key: "Tab", preventDefault() {} });
|
||||
assert.equal(document.activeElement.attributes.role, "region");
|
||||
view.render([]);
|
||||
assert.ok(walk(modal).some(node => node.textContent === "No open service requests found for this customer."));
|
||||
view.render([], "Synthetic failure");
|
||||
assert.ok(walk(modal).some(node => node.attributes.role === "alert" && node.textContent === "Synthetic failure"));
|
||||
assert.equal(app.serviceRequestsView.closeServiceRequestsModal(), true);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
view.render([{ id: "Late" }]);
|
||||
assert.equal(overlay.children.length, 1);
|
||||
});
|
||||
|
||||
test("rapid clicks share the query; different customers and late responses stay isolated", async () => {
|
||||
const { app, overlay } = setup();
|
||||
const requests = [];
|
||||
const logs = [];
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => authorizations;
|
||||
app.platformContentMessages.sendRuntimeMessage = request => new Promise(resolve => requests.push({ request, resolve }));
|
||||
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
|
||||
const button = app.serviceRequestsAction.createServiceRequestsButton(customer);
|
||||
const first = button.click();
|
||||
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
|
||||
const repeated = button.click();
|
||||
const otherButton = app.serviceRequestsAction.createServiceRequestsButton({ CustomerAccount: { PartyNumber: "other-customer" } });
|
||||
const second = otherButton.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(requests.length, 2);
|
||||
assert.deepEqual(plain(requests[0].request), message);
|
||||
requests[1].resolve({ ok: true, items: [{ id: "Other SR" }], debugRequests: [] });
|
||||
await second;
|
||||
requests[0].resolve({ ok: true, items: [{ id: "Wrong customer" }], debugRequests: [] });
|
||||
await Promise.all([first, repeated]);
|
||||
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Other SR"));
|
||||
assert.equal(walk(overlay.children[0]).some(node => node.textContent === "Wrong customer"), false);
|
||||
assert.equal(logs.length, 2);
|
||||
const retry = button.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(requests.length, 3, "results are not cached after completion");
|
||||
requests[2].resolve({ ok: false, error: "Synthetic request failure", debugRequests: [] });
|
||||
await retry;
|
||||
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Synthetic request failure"));
|
||||
});
|
||||
|
||||
test("styles allocate 100 percent, support dark mode and reduced motion, without hiding values", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/service-requests/styles.css"), "utf8");
|
||||
const overrides = [...css.matchAll(/\.service-requests-col-\w+ \{ width: (\d+)%; \}/g)].map(match => Number(match[1]));
|
||||
assert.equal(overrides.reduce((a, b) => a + b, 0) + (11 - overrides.length) * 7, 100);
|
||||
assert.match(css, /min-width: 1200px/);
|
||||
assert.match(css, /data-theme="dark"/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.doesNotMatch(css, /text-overflow: ellipsis/);
|
||||
});
|
||||
Reference in New Issue
Block a user