Compare commits
7 Commits
v1.0.2
...
4d3f1f9760
| Author | SHA1 | Date | |
|---|---|---|---|
|
4d3f1f9760
|
|||
|
a182609415
|
|||
|
1a3decbeb6
|
|||
|
de4da0eec6
|
|||
|
cc567c8ff1
|
|||
|
6a3e9e6adf
|
|||
|
86973f7542
|
90
README.md
90
README.md
@@ -19,6 +19,16 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
|||||||
|
|
||||||
## CRM Service Request Badges
|
## CRM Service Request Badges
|
||||||
|
|
||||||
|
The header's **Refresh all** button (between theme and Preferences) invalidates
|
||||||
|
persisted Products and activity caches and clears in-memory data views. It reloads
|
||||||
|
the selected Reference, fetching fresh products and then the permitted SR/Actions
|
||||||
|
queries. Customer details, rate cards, consumption charts and AI summaries are
|
||||||
|
requested again when opened. Preferences, cookies and authentication are preserved.
|
||||||
|
The button is disabled during invalidation and list reload; product/activity badges
|
||||||
|
continue to show subsequent background progress. Cache-clear failures are surfaced
|
||||||
|
instead of silently reusing cached data, and stale product responses cannot restore
|
||||||
|
an invalidated cache.
|
||||||
|
|
||||||
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
On the first Opportunities modal opening in a page session, `requestCurrentUser`
|
||||||
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
|
||||||
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
|
||||||
@@ -27,32 +37,92 @@ If identity or roles cannot be validated, only these two features stay disabled;
|
|||||||
other opportunity features continue. Reload the page to refresh identity/roles.
|
other opportunity features continue. Reload the page to refresh identity/roles.
|
||||||
The unused identity token is discarded and redacted from debug responses.
|
The unused identity token is discarded and redacted from debug responses.
|
||||||
|
|
||||||
Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of
|
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
|
||||||
|
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
|
||||||
|
preserves the originating window and Firefox cookie store, and retries within
|
||||||
|
the same five-attempt limit. Debug entries retain the HTTP status and recovery
|
||||||
|
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
|
||||||
|
|
||||||
|
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
|
||||||
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
|
||||||
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
|
||||||
query endpoint, independently of the opportunity query version. Calls appear in
|
query endpoint, independently of the opportunity query version. Calls appear in
|
||||||
the Service Requests debug category with masked credentials.
|
the Service Requests debug category with masked credentials.
|
||||||
|
After a complete successful SR query (HTTP 200 with valid responses on all pages),
|
||||||
|
its result is reused for subsequent Reference/product cycles in the current page
|
||||||
|
session. Refresh all explicitly clears it. Failed pages are retried up to five
|
||||||
|
total attempts each; completed pages are not requested again. Resetting the query
|
||||||
|
stops further retries from the previous run.
|
||||||
|
|
||||||
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
|
||||||
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
|
||||||
email and contact flags. A red badge indicates no match within that query's scope.
|
email and contact flags. A red badge indicates no match within that query's scope.
|
||||||
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
|
||||||
Badges follow Ramp Months and do not wait for product downloads. SR results remain
|
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
|
||||||
in memory only. Reference changes share the in-flight lookup and reuse its result
|
extension local storage, isolated by tokenrelay principal. The cache uses the same
|
||||||
(including failures) without another request. Closing resets this state; reopening
|
Preferences lifetime as Products (24 hours by default), anchored to the product
|
||||||
starts a fresh lookup, and responses from previous openings are ignored.
|
timestamp rather than the activity response time. Actions entries match each
|
||||||
|
product's update; the SR snapshot matches the latest product update in the cycle.
|
||||||
|
SR snapshots are stored separately by update timestamp so changing Reference does
|
||||||
|
not overwrite a previous period's snapshot. Expired snapshots are removed on
|
||||||
|
subsequent SR cache writes; Refresh all invalidates every snapshot.
|
||||||
|
No credentials, request headers or debug envelopes are stored in this cache.
|
||||||
|
Products loaded from cache restore matching activity entries without network calls.
|
||||||
|
Missing, expired or mismatched entries are not treated as confirmed empty results;
|
||||||
|
they wait for a real product refresh before a new activity query is allowed.
|
||||||
|
Empty and entirely failed product cycles do not query activities. In mixed cycles,
|
||||||
|
only fresh products permit activity API calls on cache misses; an already-loaded
|
||||||
|
SR result is reused and associated with the new product update.
|
||||||
|
Activity lookups do not advance the existing Last update timestamp.
|
||||||
|
Closing and reopening the modal preserves results and does not
|
||||||
|
reload products or trigger these lookups. Already-running product requests may
|
||||||
|
finish in the background and update their related activities.
|
||||||
|
|
||||||
## Workload Actions
|
## Workload Actions
|
||||||
|
|
||||||
Workload Actions are queried in the background after product results arrive,
|
The missing Act badge includes a separate plus control that opens an Action form
|
||||||
including cached products. Each distinct `RevnId` uses `requestWlActions` with
|
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
|
||||||
|
Opening the form loads team members and action lookups using the WWB-TS token.
|
||||||
|
Team members use the product RevnId; when multiple workloads are available, the
|
||||||
|
user selects one before its members are requested. Owner options use email for
|
||||||
|
both label and value; lookup options display name and submit code.
|
||||||
|
All Action selects support local autocomplete by label or code, arrow-key
|
||||||
|
navigation and Enter selection. Escape or leaving the field restores the last
|
||||||
|
selected option; typed search text is never submitted as an option value.
|
||||||
|
Fields are enabled only after both metadata requests return HTTP 200. Loading,
|
||||||
|
errors and manual retry are supported, and debug logs mask the credential.
|
||||||
|
Save validates all required fields and the date range before posting to the WWB
|
||||||
|
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
|
||||||
|
array of strings (empty when omitted). The current user.id supplies createdBy
|
||||||
|
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
|
||||||
|
submissions. Success closes the form and displays a toast; failure preserves the
|
||||||
|
form and displays an error toast without automatically retrying the POST.
|
||||||
|
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
|
||||||
|
users, like the other Action features.
|
||||||
|
|
||||||
|
SR and Action filter badges appear before Status for users allowed to view these
|
||||||
|
activities. They filter the loaded data without issuing requests and combine with
|
||||||
|
the existing opportunity filters. The SR below 4h option replaces the other SR
|
||||||
|
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
|
||||||
|
(including zero, excluding missing values). Action matches use the same ownership
|
||||||
|
rule as the row badge. Loading/failed lookups are not treated as missing records;
|
||||||
|
with both positive and negative options selected, all activity states remain
|
||||||
|
visible. These selections are kept in memory, not persisted.
|
||||||
|
|
||||||
|
Workload Actions are queried in the background after the product cycle completes,
|
||||||
|
and only after SR has completed successfully (or was restored/reused). If SR is
|
||||||
|
unavailable, Actions are not requested and remain unknown rather than absent.
|
||||||
|
Each failed workload lookup gets up to five total attempts, stopping on success
|
||||||
|
or when its session is superseded. Cached products still do not trigger network
|
||||||
|
lookups. Each distinct `RevnId` uses `requestWlActions` with
|
||||||
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
|
||||||
aggregates all products: green means an action's `owner` matches the general
|
aggregates all products: green means an action's `owner` matches the general
|
||||||
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
|
||||||
means ownership could not be confirmed (including a missing principal). Tooltips
|
means ownership could not be confirmed (including a missing principal). Tooltips
|
||||||
retain all returned actions, regardless of owner. Principal stays in memory only.
|
retain all returned actions, regardless of owner. Credentials stay in memory only;
|
||||||
Queries are shared per modal opening (up to four concurrently), reused
|
the principal is used to scope persisted activity entries.
|
||||||
across Reference changes, and discarded on close. Reopening queries again.
|
Queries are shared per product cycle (up to four concurrently). Reference changes
|
||||||
|
that load products from cache do not refresh activities.
|
||||||
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
|
||||||
The debug panel groups these requests under Actions, with credentials masked.
|
The debug panel groups these requests under Actions, with credentials masked.
|
||||||
|
|
||||||
|
|||||||
187
dist/chromium/background.js
vendored
187
dist/chromium/background.js
vendored
@@ -14,7 +14,12 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -249,14 +254,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +297,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +657,120 @@ function createSpaWorkloadActions({ fetchJson }) {
|
|||||||
return { requestWlActions };
|
return { requestWlActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/action-options.js
|
||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-details.js
|
||||||
|
function createFusionSrDetails({ fetchJson }) {
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInternalServiceRequest };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/current-user.js
|
// Source: src/services/spa/current-user.js
|
||||||
function createSpaCurrentUser({ fetchJson }) {
|
function createSpaCurrentUser({ fetchJson }) {
|
||||||
async function requestCurrentUser() {
|
async function requestCurrentUser() {
|
||||||
@@ -852,7 +979,7 @@ function createHttpBackgroundJson({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -873,7 +1000,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -881,6 +1011,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
@@ -1134,8 +1265,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1293,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1346,7 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,12 +1859,17 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestInternalServiceRequest,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -1739,6 +1883,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -1816,6 +1964,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestWlActions) {
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load workload actions.",
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
@@ -2006,6 +2163,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...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);
|
||||||
@@ -2013,8 +2173,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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;
|
||||||
|
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
1288
dist/chromium/content.js
vendored
1288
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
3
dist/chromium/manifest.json
vendored
3
dist/chromium/manifest.json
vendored
@@ -9,7 +9,8 @@
|
|||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
187
dist/firefox/background.js
vendored
187
dist/firefox/background.js
vendored
@@ -14,7 +14,12 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
@@ -249,14 +254,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +297,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +657,120 @@ function createSpaWorkloadActions({ fetchJson }) {
|
|||||||
return { requestWlActions };
|
return { requestWlActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source: src/services/spa/action-options.js
|
||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: src/services/fusion/sr-details.js
|
||||||
|
function createFusionSrDetails({ fetchJson }) {
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInternalServiceRequest };
|
||||||
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/current-user.js
|
// Source: src/services/spa/current-user.js
|
||||||
function createSpaCurrentUser({ fetchJson }) {
|
function createSpaCurrentUser({ fetchJson }) {
|
||||||
async function requestCurrentUser() {
|
async function requestCurrentUser() {
|
||||||
@@ -852,7 +979,7 @@ function createHttpBackgroundJson({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -873,7 +1000,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -881,6 +1011,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
@@ -1134,8 +1265,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -1153,15 +1293,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1346,7 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/products.js
|
// Source: src/services/fusion/products.js
|
||||||
@@ -1720,12 +1859,17 @@ function startBackgroundApplication(dependencies) {
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestInternalServiceRequest,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -1739,6 +1883,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -1816,6 +1964,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestWlActions) {
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load workload actions.",
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
@@ -2006,6 +2163,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...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);
|
||||||
@@ -2013,8 +2173,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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;
|
||||||
|
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
1288
dist/firefox/content.js
vendored
1288
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
3
dist/firefox/manifest.json
vendored
3
dist/firefox/manifest.json
vendored
@@ -9,7 +9,8 @@
|
|||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const baseManifest = {
|
|||||||
],
|
],
|
||||||
host_permissions: [
|
host_permissions: [
|
||||||
"https://eeho.fa.us2.oraclecloud.com/*",
|
"https://eeho.fa.us2.oraclecloud.com/*",
|
||||||
"https://spa.oracle.com/*"
|
"https://spa.oracle.com/*",
|
||||||
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
|
||||||
],
|
],
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ module.exports = {
|
|||||||
"src/features/opportunities/dashboard-state.js",
|
"src/features/opportunities/dashboard-state.js",
|
||||||
"src/features/debug/state.js",
|
"src/features/debug/state.js",
|
||||||
"src/features/opportunities/columns.js",
|
"src/features/opportunities/columns.js",
|
||||||
|
"src/features/opportunities/badge-content.js",
|
||||||
"src/content/fusion-page.js",
|
"src/content/fusion-page.js",
|
||||||
"src/content/modal.js",
|
"src/content/modal.js",
|
||||||
"src/content/theme.js",
|
"src/content/theme.js",
|
||||||
@@ -32,8 +33,15 @@ module.exports = {
|
|||||||
"src/services/fusion/opportunities.js",
|
"src/services/fusion/opportunities.js",
|
||||||
"src/services/fusion/crm-service-requests.js",
|
"src/services/fusion/crm-service-requests.js",
|
||||||
"src/features/opportunities/crm-service-requests.js",
|
"src/features/opportunities/crm-service-requests.js",
|
||||||
|
"src/features/opportunities/product-activities.js",
|
||||||
|
"src/features/opportunities/refresh-all.js",
|
||||||
|
"src/features/opportunities/activity-cache.js",
|
||||||
|
"src/features/opportunities/activity-progress.js",
|
||||||
|
"src/features/opportunities/activity-filters.js",
|
||||||
"src/features/opportunities/crm-sr-popover.js",
|
"src/features/opportunities/crm-sr-popover.js",
|
||||||
"src/features/opportunities/workload-actions.js",
|
"src/features/opportunities/workload-actions.js",
|
||||||
|
"src/features/opportunities/workload-action-form.js",
|
||||||
|
"src/features/opportunities/workload-action-options.js",
|
||||||
"src/features/opportunities/fiscal-periods.js",
|
"src/features/opportunities/fiscal-periods.js",
|
||||||
"src/features/opportunities/table.js",
|
"src/features/opportunities/table.js",
|
||||||
"src/features/opportunities/dashboard.js",
|
"src/features/opportunities/dashboard.js",
|
||||||
@@ -77,6 +85,8 @@ module.exports = {
|
|||||||
"src/services/spa/install-base.js",
|
"src/services/spa/install-base.js",
|
||||||
"src/services/spa/customer-service-requests.js",
|
"src/services/spa/customer-service-requests.js",
|
||||||
"src/services/spa/workload-actions.js",
|
"src/services/spa/workload-actions.js",
|
||||||
|
"src/services/spa/action-options.js",
|
||||||
|
"src/services/fusion/sr-details.js",
|
||||||
"src/services/spa/current-user.js",
|
"src/services/spa/current-user.js",
|
||||||
"src/services/spa/consumption.js",
|
"src/services/spa/consumption.js",
|
||||||
"src/services/spa/response-shapes.js",
|
"src/services/spa/response-shapes.js",
|
||||||
|
|||||||
@@ -103,14 +103,22 @@ function createAuthConsumerSession({
|
|||||||
const isHtmlFallback = error
|
const isHtmlFallback = error
|
||||||
&& error.code === "INVALID_JSON_RESPONSE"
|
&& error.code === "INVALID_JSON_RESPONSE"
|
||||||
&& error.httpStatus === 200;
|
&& error.httpStatus === 200;
|
||||||
|
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
|
||||||
|
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
|
||||||
|
const retryReason = isSessionUnauthorized
|
||||||
|
? `HTTP ${error.httpStatus} response requires SPA session recovery`
|
||||||
|
: "HTTP 200 response did not contain valid JSON";
|
||||||
const debugEntry = debugRequests[debugRequests.length - 1];
|
const debugEntry = debugRequests[debugRequests.length - 1];
|
||||||
|
|
||||||
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
if (isHtmlFallback) {
|
if (needsSessionRecovery) {
|
||||||
debugEntry.metadata.retryLimitReached = "true";
|
debugEntry.metadata.retryLimitReached = "true";
|
||||||
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
|
debugEntry.error = isHtmlFallback
|
||||||
|
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
|
||||||
|
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
|
||||||
error.message = debugEntry.error;
|
error.message = debugEntry.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
|
|||||||
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
|
||||||
if (debugEntry) {
|
if (debugEntry) {
|
||||||
debugEntry.metadata.attempt = String(attempt);
|
debugEntry.metadata.attempt = String(attempt);
|
||||||
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
|
debugEntry.metadata.retryReason = retryReason;
|
||||||
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
|
let userId = "";
|
||||||
function ensure() {
|
function ensure() {
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
||||||
appendBackgroundRequestLog(response?.debugRequests);
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
const roles = response?.user?.roles;
|
const roles = response?.user?.roles;
|
||||||
|
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
|
||||||
allowed = response?.ok === true && Array.isArray(roles)
|
allowed = response?.ok === true && Array.isArray(roles)
|
||||||
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
||||||
return allowed;
|
return allowed;
|
||||||
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
|
|||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
return { ensure, allowsCrmActivities: () => allowed };
|
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...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);
|
||||||
@@ -122,8 +125,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
|
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...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;
|
||||||
|
const fusionSrDetails = createFusionSrDetails({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
function start() {
|
function start() {
|
||||||
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
startBackgroundApplication({ messageTypes, requestInternalServiceRequest: message => fusionSrDetails.requestInternalServiceRequest(message), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start, contracts,
|
start, contracts,
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
|
invalidateProductsCache,
|
||||||
getXsrfTokenCookie,
|
getXsrfTokenCookie,
|
||||||
refreshXsrfCookie,
|
refreshXsrfCookie,
|
||||||
requestAccountPlans,
|
requestAccountPlans,
|
||||||
requestInstallBase,
|
requestInstallBase,
|
||||||
requestCustomerServiceRequest,
|
requestCustomerServiceRequest,
|
||||||
requestWlActions,
|
requestWlActions,
|
||||||
|
requestActionTeamMembers,
|
||||||
|
requestActionFindLookup,
|
||||||
|
requestMgmtAction,
|
||||||
|
requestInternalServiceRequest,
|
||||||
requestCurrentUser,
|
requestCurrentUser,
|
||||||
requestConsumerTokenService,
|
requestConsumerTokenService,
|
||||||
requestConsumptionTrend,
|
requestConsumptionTrend,
|
||||||
@@ -20,6 +25,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === messageTypes.invalidateProductsCache) {
|
||||||
|
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === messageTypes.getXsrfToken) {
|
if (message.type === messageTypes.getXsrfToken) {
|
||||||
getXsrfTokenCookie()
|
getXsrfTokenCookie()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -97,6 +106,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
|
||||||
|
request(message).then(sendResponse).catch(error => sendResponse({
|
||||||
|
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
|
||||||
|
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === messageTypes.requestWlActions) {
|
if (message.type === messageTypes.requestWlActions) {
|
||||||
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
|
||||||
ok: false, error: error.message || "Unable to load workload actions.",
|
ok: false, error: error.message || "Unable to load workload actions.",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
const opportunitiesTableState = createOpportunitiesTableState();
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
const preferencesState = createPreferencesState(contracts);
|
const preferencesState = createPreferencesState(contracts);
|
||||||
|
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
|
||||||
const productsContentState = createProductsContentState();
|
const productsContentState = createProductsContentState();
|
||||||
const aiSummaryState = createAiSummaryState();
|
const aiSummaryState = createAiSummaryState();
|
||||||
const consumptionPlansState = createConsumptionPlansState();
|
const consumptionPlansState = createConsumptionPlansState();
|
||||||
@@ -54,6 +55,9 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const contentModal = createContentModal({
|
const contentModal = createContentModal({
|
||||||
|
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||||
|
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||||
|
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||||
ensureCurrentUser: () => currentUser.ensure(),
|
ensureCurrentUser: () => currentUser.ensure(),
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
AUTH_STATUS: contracts.AUTH_STATUS,
|
AUTH_STATUS: contracts.AUTH_STATUS,
|
||||||
@@ -88,6 +92,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
|
||||||
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
|
||||||
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
|
||||||
|
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
|
||||||
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
|
||||||
@@ -110,10 +115,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
|
||||||
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
|
||||||
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
|
|
||||||
reopenWorkloadActions: () => opportunityWorkloadActions.reopen(),
|
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
|
||||||
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
|
||||||
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
|
||||||
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
|
||||||
@@ -189,7 +190,6 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
|
||||||
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
|
||||||
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
|
|
||||||
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
|
||||||
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
|
||||||
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
|
||||||
@@ -217,11 +217,21 @@ function createContentApplication(overrides = {}) {
|
|||||||
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
maskToken: (...args) => debugContentLog.maskToken(...args), environment
|
||||||
});
|
});
|
||||||
const crmSrPopover = createCrmSrPopover({
|
const crmSrPopover = createCrmSrPopover({
|
||||||
|
requestDetails: async srId => {
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load SR details.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
|
||||||
});
|
});
|
||||||
|
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
|
||||||
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("sr", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
bindPopover: (...args) => crmSrPopover.bind(...args),
|
bindPopover: (...args) => crmSrPopover.bind(...args),
|
||||||
closePopover: (...args) => crmSrPopover.close(...args),
|
closePopover: (...args) => crmSrPopover.close(...args),
|
||||||
@@ -229,6 +239,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
|
||||||
});
|
});
|
||||||
const opportunitiesTable = createOpportunitiesTable({
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
renderActivityFilters: () => activityFilters.render(),
|
||||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
|
||||||
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
|
||||||
@@ -266,6 +277,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const opportunitiesFilters = createOpportunitiesFilters({
|
const opportunitiesFilters = createOpportunitiesFilters({
|
||||||
|
matchesActivities: item => activityFilters.matches(item),
|
||||||
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
|
||||||
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
|
||||||
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
|
||||||
@@ -329,8 +341,50 @@ function createContentApplication(overrides = {}) {
|
|||||||
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const refreshAll = createRefreshAll({
|
||||||
|
createRefreshIcon: () => contentIcons.createRefreshIcon(),
|
||||||
|
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
|
||||||
|
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
|
||||||
|
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
resetRelatedData: () => {
|
||||||
|
opportunityCrmServiceRequests.reset();
|
||||||
|
opportunityWorkloadActions.reset();
|
||||||
|
consumptionPlansView.closeAccountPlansModal();
|
||||||
|
installBaseView.closeInstallBaseModal();
|
||||||
|
serviceRequestsView.closeServiceRequestsModal();
|
||||||
|
installBaseAction.resetRequests();
|
||||||
|
serviceRequestsAction.resetRequests();
|
||||||
|
ratecardController.resetRequests();
|
||||||
|
consumptionPlanDetailController.resetRequests();
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||||
|
const activityFilters = createActivityFilters({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
|
||||||
|
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
|
||||||
|
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
|
const productActivities = createProductActivities({
|
||||||
|
productsContentState, opportunitiesQueryState,
|
||||||
|
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
|
||||||
|
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
||||||
|
blockActions: products => opportunityWorkloadActions.blockProducts(products),
|
||||||
|
render: () => {
|
||||||
|
productsContentJobs.renderProductsProgress();
|
||||||
|
productsContentJobs.queueProductsTableRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
const productsContentJobs = createProductsContentJobs({
|
const productsContentJobs = createProductsContentJobs({
|
||||||
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
|
beginProductActivities: (...args) => productActivities.begin(...args),
|
||||||
|
completeProductActivities: (...args) => productActivities.complete(...args),
|
||||||
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
@@ -342,7 +396,34 @@ function createContentApplication(overrides = {}) {
|
|||||||
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
|
const workloadActionOptions = createWorkloadActionOptions({
|
||||||
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
|
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
|
messageTypes
|
||||||
|
});
|
||||||
|
const workloadActionForm = createWorkloadActionForm({
|
||||||
|
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
|
||||||
|
saveAction: (...args) => workloadActionOptions.save(...args),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
getCurrentUserId: () => currentUser.getUserId(),
|
||||||
|
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
|
||||||
|
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
|
||||||
|
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
|
||||||
|
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
|
||||||
|
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
|
||||||
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
|
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
|
||||||
|
createCloseIcon: () => contentIcons.createCloseIcon(),
|
||||||
|
environment
|
||||||
|
});
|
||||||
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
const opportunityWorkloadActions = createOpportunityWorkloadActions({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
|
createActionButton: number => workloadActionForm.createButton(number),
|
||||||
|
closeActionForm: (...args) => workloadActionForm.close(...args),
|
||||||
|
activityCache,
|
||||||
|
reportProgress: (...args) => activityProgress.update("actions", ...args),
|
||||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||||
authSessionState,
|
authSessionState,
|
||||||
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
|
||||||
@@ -352,6 +433,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
productsContentState, messageTypes, environment
|
productsContentState, messageTypes, environment
|
||||||
});
|
});
|
||||||
const productsDetails = createProductsDetails({
|
const productsDetails = createProductsDetails({
|
||||||
|
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
|
||||||
OPPORTUNITIES_COLUMNS: columns,
|
OPPORTUNITIES_COLUMNS: columns,
|
||||||
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
|
||||||
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
|
||||||
@@ -515,6 +597,8 @@ function createContentApplication(overrides = {}) {
|
|||||||
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
workloadActionForm,
|
||||||
|
activityFilters,
|
||||||
start, contracts,
|
start, contracts,
|
||||||
opportunityWorkloadActions,
|
opportunityWorkloadActions,
|
||||||
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
fusionCrmServiceRequests, opportunityCrmServiceRequests,
|
||||||
@@ -552,6 +636,10 @@ function createContentApplication(overrides = {}) {
|
|||||||
preferencesStorage,
|
preferencesStorage,
|
||||||
preferencesView,
|
preferencesView,
|
||||||
productsContentJobs,
|
productsContentJobs,
|
||||||
|
productActivities,
|
||||||
|
activityProgress,
|
||||||
|
refreshAll,
|
||||||
|
activityCache,
|
||||||
productsDetails,
|
productsDetails,
|
||||||
aiSummaryContent,
|
aiSummaryContent,
|
||||||
consumptionPlansAction,
|
consumptionPlansAction,
|
||||||
|
|||||||
@@ -70,5 +70,20 @@ function createContentIcons({
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createThemeIcon, createSettingsIcon, createCloseIcon };
|
function createRefreshIcon() {
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.setAttribute("focusable", "false");
|
||||||
|
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", "currentColor");
|
||||||
|
path.setAttribute("stroke-width", "1.8");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.append(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ function createContentModal({
|
|||||||
closeAccountPlansModal,
|
closeAccountPlansModal,
|
||||||
closeInstallBaseModal,
|
closeInstallBaseModal,
|
||||||
closeServiceRequestsModal,
|
closeServiceRequestsModal,
|
||||||
|
closeActionForm,
|
||||||
closeCustomerFilterPanel,
|
closeCustomerFilterPanel,
|
||||||
closeOwnerFilterPanel,
|
closeOwnerFilterPanel,
|
||||||
closePreferencesModal,
|
closePreferencesModal,
|
||||||
createSettingsIcon,
|
createSettingsIcon,
|
||||||
|
createRefreshAllButton,
|
||||||
|
createActivityFilterControls,
|
||||||
debugState,
|
debugState,
|
||||||
ensureExtensionStyles,
|
ensureExtensionStyles,
|
||||||
getCustomerOptions,
|
getCustomerOptions,
|
||||||
@@ -51,10 +54,7 @@ function createContentModal({
|
|||||||
renderStageFilterButtons,
|
renderStageFilterButtons,
|
||||||
renderStatusFilterButtons,
|
renderStatusFilterButtons,
|
||||||
requestOpportunitiesForPeriod,
|
requestOpportunitiesForPeriod,
|
||||||
loadCrmServiceRequests,
|
closeCrmSrPopover,
|
||||||
resetCrmServiceRequests,
|
|
||||||
resetWorkloadActions,
|
|
||||||
reopenWorkloadActions,
|
|
||||||
requestTokenRelayOnce,
|
requestTokenRelayOnce,
|
||||||
ensureCurrentUser,
|
ensureCurrentUser,
|
||||||
resetCustomerAndOwnerFiltersForPeriod,
|
resetCustomerAndOwnerFiltersForPeriod,
|
||||||
@@ -88,10 +88,7 @@ function createContentModal({
|
|||||||
ensureEscapeKeyHandler();
|
ensureEscapeKeyHandler();
|
||||||
ensureCurrentUser().then(() => {
|
ensureCurrentUser().then(() => {
|
||||||
if (existingModal.hidden) return;
|
if (existingModal.hidden) return;
|
||||||
reopenWorkloadActions();
|
return requestTokenRelayOnce();
|
||||||
return requestTokenRelayOnce().then(token => {
|
|
||||||
if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.then(() => renderOpportunitiesTable()).catch(() => {});
|
.then(() => renderOpportunitiesTable()).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +111,6 @@ function createContentModal({
|
|||||||
overlay.setAttribute("role", "dialog");
|
overlay.setAttribute("role", "dialog");
|
||||||
overlay.setAttribute("aria-modal", "true");
|
overlay.setAttribute("aria-modal", "true");
|
||||||
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
|
||||||
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
|
|
||||||
|
|
||||||
const shell = document.createElement("section");
|
const shell = document.createElement("section");
|
||||||
shell.className = "opportunities-extension-shell";
|
shell.className = "opportunities-extension-shell";
|
||||||
@@ -126,10 +122,6 @@ function createContentModal({
|
|||||||
title.id = "opportunities-extension-title";
|
title.id = "opportunities-extension-title";
|
||||||
title.textContent = "Opportunities Extension";
|
title.textContent = "Opportunities Extension";
|
||||||
|
|
||||||
const subtitle = document.createElement("p");
|
|
||||||
subtitle.id = "opportunities-extension-subtitle";
|
|
||||||
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
|
|
||||||
|
|
||||||
const authBadge = document.createElement("span");
|
const authBadge = document.createElement("span");
|
||||||
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
|
||||||
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
|
||||||
@@ -149,9 +141,10 @@ function createContentModal({
|
|||||||
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
|
||||||
productsCacheTimestamp.setAttribute("role", "status");
|
productsCacheTimestamp.setAttribute("role", "status");
|
||||||
productsCacheTimestamp.hidden = true;
|
productsCacheTimestamp.hidden = true;
|
||||||
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
|
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
|
||||||
|
|
||||||
const titleBlock = document.createElement("div");
|
const titleBlock = document.createElement("div");
|
||||||
|
titleBlock.className = "opportunities-extension-title-row";
|
||||||
titleBlock.append(title, subtitleRow);
|
titleBlock.append(title, subtitleRow);
|
||||||
|
|
||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
@@ -180,7 +173,7 @@ function createContentModal({
|
|||||||
|
|
||||||
const headerActions = document.createElement("div");
|
const headerActions = document.createElement("div");
|
||||||
headerActions.className = "opportunities-extension-header-actions";
|
headerActions.className = "opportunities-extension-header-actions";
|
||||||
headerActions.append(themeButton, preferencesButton, closeButton);
|
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
|
||||||
|
|
||||||
header.append(titleBlock, headerActions);
|
header.append(titleBlock, headerActions);
|
||||||
|
|
||||||
@@ -389,7 +382,8 @@ function createContentModal({
|
|||||||
|
|
||||||
statusFilter.append(statusFilterLabel, statusFilters);
|
statusFilter.append(statusFilterLabel, statusFilters);
|
||||||
form.addEventListener("submit", (event) => event.preventDefault());
|
form.addEventListener("submit", (event) => event.preventDefault());
|
||||||
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
|
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
|
||||||
|
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
|
||||||
body.append(form);
|
body.append(form);
|
||||||
|
|
||||||
const stageDashboard = document.createElement("section");
|
const stageDashboard = document.createElement("section");
|
||||||
@@ -469,8 +463,7 @@ function createContentModal({
|
|||||||
|
|
||||||
function closeOpportunitiesModal() {
|
function closeOpportunitiesModal() {
|
||||||
resetAuthCompletion();
|
resetAuthCompletion();
|
||||||
resetWorkloadActions(false);
|
closeCrmSrPopover();
|
||||||
resetCrmServiceRequests();
|
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -478,6 +471,7 @@ function createContentModal({
|
|||||||
closeAccountPlansModal();
|
closeAccountPlansModal();
|
||||||
closeInstallBaseModal();
|
closeInstallBaseModal();
|
||||||
closeServiceRequestsModal();
|
closeServiceRequestsModal();
|
||||||
|
closeActionForm(false);
|
||||||
modal.hidden = true;
|
modal.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +500,10 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
if (closeActionForm()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (closeServiceRequestsModal()) {
|
if (closeServiceRequestsModal()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
current.promise = (async () => {
|
current.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
|
||||||
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
|
if (!pending.has(subPlanNum)) {
|
||||||
|
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
|
||||||
|
pending.set(subPlanNum, promise);
|
||||||
|
}
|
||||||
const items = await pending.get(subPlanNum);
|
const items = await pending.get(subPlanNum);
|
||||||
if (!section.isConnected || states.get(section) !== current) return;
|
if (!section.isConnected || states.get(section) !== current) return;
|
||||||
current.status = "ready";
|
current.status = "ready";
|
||||||
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
|
|||||||
})();
|
})();
|
||||||
return current.promise;
|
return current.promise;
|
||||||
}
|
}
|
||||||
return { createPlanDetailSection, loadPlanDetailForPanel };
|
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
const currentRequests = consumptionPlansState.accountPlansRequestState;
|
||||||
|
currentRequests.set(optyNumber, { status: "loading" });
|
||||||
openAccountPlansModal(opportunity, [], "loading");
|
openAccountPlansModal(opportunity, [], "loading");
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
requestConsumerTokenServiceOnce()
|
requestConsumerTokenServiceOnce()
|
||||||
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
|
|||||||
authorizations
|
authorizations
|
||||||
}))
|
}))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
appendBackgroundRequestLog(response && response.debugRequests);
|
appendBackgroundRequestLog(response && response.debugRequests);
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
if (!response || !response.ok) {
|
||||||
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
|
|||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
|
||||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||||
status: "error",
|
status: "error",
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ function createDebugContentView({
|
|||||||
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||||
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 === "requestWlActions") return "actions";
|
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
|
||||||
if (entry.name === "requestGenerateAiSummary") return "ai";
|
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||||
if (entry.name === "requestInstallBase") return "installBase";
|
if (entry.name === "requestInstallBase") return "installBase";
|
||||||
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
|
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
|
||||||
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,14 @@ function createDebugContentView({
|
|||||||
method.className = "opportunities-extension-debug-method";
|
method.className = "opportunities-extension-debug-method";
|
||||||
method.textContent = entry.method || "GET";
|
method.textContent = entry.method || "GET";
|
||||||
method.setAttribute("data-method", method.textContent.toUpperCase());
|
method.setAttribute("data-method", method.textContent.toUpperCase());
|
||||||
summaryMeta.append(startedTime, method, httpStatus, status);
|
const duration = document.createElement("span");
|
||||||
|
duration.className = "opportunities-extension-debug-duration";
|
||||||
|
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
|
||||||
|
duration.textContent = hasDuration
|
||||||
|
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
|
||||||
|
: entry.status === "pending" ? "In progress" : "N/D";
|
||||||
|
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
|
||||||
|
summaryMeta.append(startedTime, method, httpStatus, status, duration);
|
||||||
|
|
||||||
heading.append(name, summaryMeta);
|
heading.append(name, summaryMeta);
|
||||||
|
|
||||||
|
|||||||
@@ -214,3 +214,10 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
|
||||||
outline-color: #8cc8d3;
|
outline-color: #8cc8d3;
|
||||||
}
|
}
|
||||||
|
.opportunities-extension-debug-duration {
|
||||||
|
color: #55514d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
pending.set(partyNumber, request);
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createInstallBaseButton };
|
return { createInstallBaseButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/features/opportunities/activity-cache.js
Normal file
67
src/features/opportunities/activity-cache.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
function createActivityCache({ authSessionState, preferencesState, environment }) {
|
||||||
|
const { browser, chrome } = environment;
|
||||||
|
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
|
||||||
|
async function invalidate() {
|
||||||
|
await storage("set", { [invalidatedKey]: Date.now() });
|
||||||
|
const entries = await storage("get", null);
|
||||||
|
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
|
||||||
|
if (keys.length) await storage("remove", keys);
|
||||||
|
}
|
||||||
|
function storage(method, argument) {
|
||||||
|
if (browser?.storage?.local) return browser.storage.local[method](argument);
|
||||||
|
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local[method](argument, result => {
|
||||||
|
const error = chrome.runtime?.lastError;
|
||||||
|
if (error) reject(new Error(error.message));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function context(kind, id, timestamp) {
|
||||||
|
const principal = authSessionState.principal;
|
||||||
|
const fetchedAt = Number(timestamp);
|
||||||
|
const hours = Number(preferencesState.opptyProductsCacheHours);
|
||||||
|
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
|
||||||
|
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
|
||||||
|
const identity = [principal, kind, String(id)];
|
||||||
|
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
|
||||||
|
// SR snapshots from different product updates must not overwrite each other.
|
||||||
|
if (kind === "sr") identity.push(fetchedAt);
|
||||||
|
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
|
||||||
|
}
|
||||||
|
async function read(kind, id, timestamp) {
|
||||||
|
const scope = context(kind, id, timestamp);
|
||||||
|
if (!scope) return null;
|
||||||
|
try {
|
||||||
|
const invalidated = await storage("get", invalidatedKey);
|
||||||
|
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
|
||||||
|
const result = await storage("get", scope.key);
|
||||||
|
let entry = result?.[scope.key];
|
||||||
|
if (!entry && scope.key !== scope.legacyKey) {
|
||||||
|
const legacy = await storage("get", scope.legacyKey);
|
||||||
|
entry = legacy?.[scope.legacyKey];
|
||||||
|
}
|
||||||
|
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
|
||||||
|
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||||
|
&& typeof entry.hasNext === "boolean" ? entry : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
async function write(kind, id, timestamp, items, hasNext = false) {
|
||||||
|
const scope = context(kind, id, timestamp);
|
||||||
|
if (!scope || !Array.isArray(items)) return;
|
||||||
|
try {
|
||||||
|
const invalidated = await storage("get", invalidatedKey);
|
||||||
|
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
|
||||||
|
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
|
||||||
|
if (kind === "sr") {
|
||||||
|
const entries = await storage("get", null);
|
||||||
|
const prefix = `${scope.legacyKey.slice(0, -1)},`;
|
||||||
|
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
|
||||||
|
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
|
||||||
|
if (expired.length) await storage("remove", expired);
|
||||||
|
}
|
||||||
|
} catch { /* Storage failure must not discard successfully fetched data. */ }
|
||||||
|
}
|
||||||
|
return { invalidate, read, write };
|
||||||
|
}
|
||||||
91
src/features/opportunities/activity-filters.js
Normal file
91
src/features/opportunities/activity-filters.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
|
||||||
|
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
|
||||||
|
let container = null;
|
||||||
|
let form = null;
|
||||||
|
const groups = [];
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
function createControls(parentForm, statusFilter, stageFilter) {
|
||||||
|
form = parentForm;
|
||||||
|
groups.length = 0;
|
||||||
|
buttons.length = 0;
|
||||||
|
container = environment.document.createElement("div");
|
||||||
|
container.className = "opportunities-extension-badge-filters";
|
||||||
|
for (const [kind, title, options] of [
|
||||||
|
["sr", "Service Request", ["matched", "under4", "missing"]],
|
||||||
|
["actions", "WL Action", ["matched", "missing"]]
|
||||||
|
]) {
|
||||||
|
const section = environment.document.createElement("section");
|
||||||
|
section.className = "opportunities-extension-activity-filter";
|
||||||
|
const label = environment.document.createElement("span");
|
||||||
|
label.textContent = title;
|
||||||
|
const tooltip = kind === "sr"
|
||||||
|
? "Whether a Service Request has been created for the opportunities."
|
||||||
|
: "Whether the Action is registered in Workload Workbench.";
|
||||||
|
label.title = tooltip;
|
||||||
|
const controls = environment.document.createElement("div");
|
||||||
|
controls.className = "opportunities-extension-activity-filter-controls";
|
||||||
|
controls.setAttribute("role", "group");
|
||||||
|
controls.setAttribute("aria-label", `Filter by ${title}`);
|
||||||
|
for (const value of options) {
|
||||||
|
const button = environment.document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-activity-filter-button";
|
||||||
|
button.setAttribute("data-value", value);
|
||||||
|
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
|
||||||
|
const description = value === "under4"
|
||||||
|
? "At least one SR with reported hours below 4; replaces other SR filters."
|
||||||
|
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
|
||||||
|
button.title = `${tooltip}\n${description}`;
|
||||||
|
button.setAttribute("aria-label", description);
|
||||||
|
button.addEventListener("click", () => toggle(kind, value));
|
||||||
|
buttons.push({ button, kind, value });
|
||||||
|
controls.append(button);
|
||||||
|
}
|
||||||
|
section.append(label, controls);
|
||||||
|
groups.push(section);
|
||||||
|
container.append(section);
|
||||||
|
}
|
||||||
|
container.append(statusFilter, stageFilter);
|
||||||
|
render();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!container) return;
|
||||||
|
const allowed = allowsCrmActivities();
|
||||||
|
form.setAttribute("data-activity-filters", String(allowed));
|
||||||
|
for (const group of groups) group.hidden = !allowed;
|
||||||
|
for (const { button, kind, value } of buttons) {
|
||||||
|
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(kind, value) {
|
||||||
|
const selected = selections[kind];
|
||||||
|
if (!selected || !allowsCrmActivities()) return;
|
||||||
|
if (selected.has(value)) selected.delete(value);
|
||||||
|
else {
|
||||||
|
if (value === "under4") selected.clear();
|
||||||
|
else selected.delete("under4");
|
||||||
|
selected.add(value);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSelection(selected, summary) {
|
||||||
|
// Unrestricted filters retain rows whose activities are not known yet.
|
||||||
|
if (selected.has("matched") && selected.has("missing")) return true;
|
||||||
|
if (!summary) return false;
|
||||||
|
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
|
||||||
|
return selected.has(summary.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(item) {
|
||||||
|
if (!allowsCrmActivities()) return true;
|
||||||
|
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
|
||||||
|
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
|
||||||
|
}
|
||||||
|
return { createControls, render, toggle, matches };
|
||||||
|
}
|
||||||
38
src/features/opportunities/activity-progress.js
Normal file
38
src/features/opportunities/activity-progress.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
function createActivityProgress({ MODAL_ID, environment }) {
|
||||||
|
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||||
|
const entries = new Map();
|
||||||
|
function update(key, state, completed = 0, total = null) {
|
||||||
|
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
|
||||||
|
if (!host) return;
|
||||||
|
let entry = entries.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "opportunities-extension-products-progress";
|
||||||
|
badge.setAttribute("role", "status");
|
||||||
|
badge.setAttribute("aria-live", "polite");
|
||||||
|
host.append(badge);
|
||||||
|
entry = { badge, timer: null, hideTimer: null, state: "idle" };
|
||||||
|
entries.set(key, entry);
|
||||||
|
}
|
||||||
|
if (state === "complete" && entry.state === "complete") return;
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
clearTimeout(entry.hideTimer);
|
||||||
|
entry.state = state;
|
||||||
|
const label = key === "sr" ? "Service Requests" : "Actions";
|
||||||
|
entry.badge.hidden = state === "idle";
|
||||||
|
entry.badge.setAttribute("data-state", state);
|
||||||
|
entry.badge.setAttribute("data-fading", "false");
|
||||||
|
entry.badge.setAttribute("aria-busy", String(state === "running"));
|
||||||
|
const count = total === null ? String(completed) : `${completed}/${total}`;
|
||||||
|
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||||
|
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||||
|
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||||
|
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
|
||||||
|
if (state === "complete") entry.timer = setTimeout(() => {
|
||||||
|
entry.badge.setAttribute("data-fading", "true");
|
||||||
|
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
63
src/features/opportunities/badge-content.js
Normal file
63
src/features/opportunities/badge-content.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
|
||||||
|
* https://github.com/lucide-icons/lucide (ISC License)
|
||||||
|
* Copyright (c) 2026 Lucide Icons and Contributors
|
||||||
|
* Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
* purpose with or without fee is hereby granted, provided that the above
|
||||||
|
* copyright notice and this permission notice appear in all copies.
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*/
|
||||||
|
function createOpportunityBadgeContent({ environment }) {
|
||||||
|
const shapes = {
|
||||||
|
products: [
|
||||||
|
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
|
||||||
|
["path", { d: "M12 22V12" }],
|
||||||
|
["polyline", { points: "3.29 7 12 12 20.71 7" }],
|
||||||
|
["path", { d: "m7.5 4.27 9 5.15" }]
|
||||||
|
],
|
||||||
|
ramp: [
|
||||||
|
["path", { d: "M5 21v-6" }],
|
||||||
|
["path", { d: "M12 21V9" }],
|
||||||
|
["path", { d: "M19 21V3" }]
|
||||||
|
],
|
||||||
|
sr: [
|
||||||
|
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
|
||||||
|
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
|
||||||
|
["path", { d: "M12 11h4" }],
|
||||||
|
["path", { d: "M12 16h4" }],
|
||||||
|
["path", { d: "M8 11h.01" }],
|
||||||
|
["path", { d: "M8 16h.01" }]
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
["path", { d: "M13 5h8" }],
|
||||||
|
["path", { d: "M13 12h8" }],
|
||||||
|
["path", { d: "M13 19h8" }],
|
||||||
|
["path", { d: "m3 17 2 2 4-4" }],
|
||||||
|
["path", { d: "m3 7 2 2 4-4" }]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
function setContent(badge, kind, value) {
|
||||||
|
badge.textContent = String(value);
|
||||||
|
const namespace = "http://www.w3.org/2000/svg";
|
||||||
|
const icon = environment.document.createElementNS(namespace, "svg");
|
||||||
|
for (const [name, attribute] of Object.entries({
|
||||||
|
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
|
||||||
|
width: "16", height: "16", fill: "none", stroke: "currentColor",
|
||||||
|
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
|
||||||
|
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
|
||||||
|
})) icon.setAttribute(name, attribute);
|
||||||
|
for (const [tag, attributes] of shapes[kind]) {
|
||||||
|
const shape = environment.document.createElementNS(namespace, tag);
|
||||||
|
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
|
||||||
|
icon.append(shape);
|
||||||
|
}
|
||||||
|
badge.append(icon);
|
||||||
|
}
|
||||||
|
return { setContent };
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ function createOpportunitiesController({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderProductsProgress,
|
renderProductsProgress,
|
||||||
requestOpportunitiesPage,
|
requestOpportunitiesPage,
|
||||||
loadCrmServiceRequests,
|
|
||||||
resetCrmServiceRequests,
|
resetCrmServiceRequests,
|
||||||
resetWorkloadActions,
|
resetWorkloadActions,
|
||||||
requestOpptyProductsInBackground,
|
requestOpptyProductsInBackground,
|
||||||
@@ -70,7 +69,6 @@ function createOpportunitiesController({
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
cacheHits: 0
|
cacheHits: 0
|
||||||
};
|
};
|
||||||
productsContentState.productsLastCachedAt = 0;
|
|
||||||
consumptionPlansState.accountPlansRequestState = new Map();
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
consumptionTrendState.consumptionTrendCache = new Map();
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
@@ -84,7 +82,6 @@ function createOpportunitiesController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadCrmServiceRequests(token, requestVersion);
|
|
||||||
if (!isCurrentPeriodRequest(requestVersion)) return;
|
if (!isCurrentPeriodRequest(requestVersion)) return;
|
||||||
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
|
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
|
||||||
let status = "idle";
|
let status = "idle";
|
||||||
let byOpportunity = new Map();
|
let byOpportunity = new Map();
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
let loadedItems = [];
|
||||||
function load(token, version) {
|
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
|
||||||
|
function load(token, version, cacheOptions = null) {
|
||||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||||
if (!pending) pending = run(token, version, generation);
|
if (status === "ready") {
|
||||||
|
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
|
||||||
|
}
|
||||||
|
if (!pending) {
|
||||||
|
const request = run(token, version, generation, cacheOptions);
|
||||||
|
pending = request;
|
||||||
|
request.finally(() => { if (pending === request) pending = null; });
|
||||||
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
async function run(token, version, currentGeneration) {
|
async function run(token, version, currentGeneration, cacheOptions) {
|
||||||
const isActive = () => currentGeneration === generation;
|
const isActive = () => currentGeneration === generation;
|
||||||
status = "loading";
|
status = "loading";
|
||||||
try {
|
try {
|
||||||
const items = await requestCrmServiceRequest(token, version, isActive);
|
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||||
if (!items || !isActive()) return;
|
if (!isActive()) return false;
|
||||||
|
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
|
||||||
|
let items = cached?.items;
|
||||||
|
if (!items) {
|
||||||
|
reportProgress("running", 0);
|
||||||
|
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||||
|
if (isActive()) reportProgress("running", count, total);
|
||||||
|
});
|
||||||
|
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||||
|
}
|
||||||
|
if (!items || !isActive()) return false;
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
const number = item?.OpportunityName_Id_c?.OptyNumber;
|
||||||
@@ -24,18 +42,38 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
grouped.get(key).push(item);
|
grouped.get(key).push(item);
|
||||||
}
|
}
|
||||||
byOpportunity = grouped;
|
byOpportunity = grouped;
|
||||||
|
loadedItems = items;
|
||||||
status = "ready";
|
status = "ready";
|
||||||
|
if (!cached) reportProgress("complete", items.length, items.length);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive()) status = "error";
|
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function getSummary(optyNumber) {
|
||||||
|
if (!allowsCrmActivities() || !optyNumber) return null;
|
||||||
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
|
const hours = items.map(item => item.ReportedHours_c).filter(value =>
|
||||||
|
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
|
||||||
|
).map(Number);
|
||||||
|
return {
|
||||||
|
state: status === "ready" ? items.length ? "matched" : "missing" : status,
|
||||||
|
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
|
||||||
|
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
|
||||||
|
};
|
||||||
|
}
|
||||||
function createBadge(optyNumber) {
|
function createBadge(optyNumber) {
|
||||||
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
|
||||||
const items = byOpportunity.get(String(optyNumber)) || [];
|
const items = byOpportunity.get(String(optyNumber)) || [];
|
||||||
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge";
|
badge.className = "opportunities-extension-crm-sr-badge";
|
||||||
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
|
||||||
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
|
const summary = getSummary(optyNumber);
|
||||||
|
const reportedHours = summary.reportedHours !== null
|
||||||
|
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||||
|
: "N/D";
|
||||||
|
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
|
||||||
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
||||||
const descriptions = items.map(item => [
|
const descriptions = items.map(item => [
|
||||||
`Title: ${display(item.Title)}`,
|
`Title: ${display(item.Title)}`,
|
||||||
@@ -49,10 +87,10 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
|
||||||
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
|
||||||
badge.setAttribute("aria-label", description);
|
badge.setAttribute("aria-label", description);
|
||||||
|
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||||
else badge.title = description;
|
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, load, createBadge };
|
return { reset, load, createBadge, getSummary };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
|
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, environment }) {
|
||||||
const { document, window, setTimeout, clearTimeout } = environment;
|
const { document, window, setTimeout, clearTimeout } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -74,6 +74,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
dismiss.append(createCloseIcon());
|
dismiss.append(createCloseIcon());
|
||||||
dismiss.addEventListener("click", () => close(true));
|
dismiss.addEventListener("click", () => close(true));
|
||||||
panel.append(dismiss);
|
panel.append(dismiss);
|
||||||
|
const detailLoads = [];
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
const heading = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
@@ -94,6 +95,52 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
details.className = "opportunities-extension-sr-details";
|
details.className = "opportunities-extension-sr-details";
|
||||||
details.textContent = descriptions[index];
|
details.textContent = descriptions[index];
|
||||||
section.append(heading, details);
|
section.append(heading, details);
|
||||||
|
const children = document.createElement("div");
|
||||||
|
children.className = "opportunities-extension-sr-children";
|
||||||
|
children.setAttribute("aria-busy", "true");
|
||||||
|
children.setAttribute("role", "status");
|
||||||
|
children.textContent = "Loading SR details...";
|
||||||
|
section.append(children);
|
||||||
|
detailLoads.push(async () => {
|
||||||
|
try {
|
||||||
|
const records = await requestDetails(item.SrId);
|
||||||
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
|
children.textContent = "";
|
||||||
|
if (!records.length) children.textContent = "No further details.";
|
||||||
|
for (const record of records) {
|
||||||
|
const child = document.createElement("section");
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "opportunities-extension-sr-number";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = "Internal SR Number:";
|
||||||
|
const link = document.createElement(record.SrNumber ? "a" : "span");
|
||||||
|
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
|
||||||
|
if (record.SrNumber) {
|
||||||
|
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
}
|
||||||
|
row.append(label, link);
|
||||||
|
if (record.SrNumber) row.append(createCopyButton(String(record.SrNumber), "SR number", "SR Number"));
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "opportunities-extension-sr-details";
|
||||||
|
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
||||||
|
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
|
||||||
|
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(record[key])}`).join("\n");
|
||||||
|
child.append(row, body);
|
||||||
|
children.append(child);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (active !== entry || entry.panel !== panel) return;
|
||||||
|
children.setAttribute("role", "alert");
|
||||||
|
children.textContent = error.message || "Unable to load SR details.";
|
||||||
|
} finally {
|
||||||
|
if (active === entry && entry.panel === panel) {
|
||||||
|
children.setAttribute("aria-busy", "false");
|
||||||
|
entry.position();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
panel.append(section);
|
panel.append(section);
|
||||||
});
|
});
|
||||||
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
|
||||||
@@ -101,6 +148,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
|
|||||||
panel.addEventListener("focusout", focusOut);
|
panel.addEventListener("focusout", focusOut);
|
||||||
host.append(panel);
|
host.append(panel);
|
||||||
active = entry;
|
active = entry;
|
||||||
|
detailLoads.forEach(load => load());
|
||||||
button.setAttribute("aria-expanded", "true");
|
button.setAttribute("aria-expanded", "true");
|
||||||
entry.position();
|
entry.position();
|
||||||
document.addEventListener("pointerdown", entry.outside, true);
|
document.addEventListener("pointerdown", entry.outside, true);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document, requestAnimationFrame, performance } = environment;
|
const { document, requestAnimationFrame, performance } = environment;
|
||||||
function renderStageDashboard(items) {
|
function renderStageDashboard(items, totalCount = items.length) {
|
||||||
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
|
|||||||
totalCard.setAttribute("data-stage", "TOTAL");
|
totalCard.setAttribute("data-stage", "TOTAL");
|
||||||
totalLabel.textContent = "Total Opportunities";
|
totalLabel.textContent = "Total Opportunities";
|
||||||
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
totalValue.className = "opportunities-extension-stage-dashboard-value";
|
||||||
totalValue.setAttribute("aria-label", "Total opportunities");
|
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
|
||||||
animateStageDashboardValue(
|
animateStageDashboardValue(
|
||||||
totalValue,
|
totalValue,
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
items.length,
|
items.length,
|
||||||
(amount) => Math.round(amount).toLocaleString()
|
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
|
||||||
);
|
);
|
||||||
totalCard.append(totalLabel, totalValue);
|
totalCard.append(totalLabel, totalValue);
|
||||||
dashboard.append(totalCard);
|
dashboard.append(totalCard);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
|
|||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
saveFilterPreferences,
|
saveFilterPreferences,
|
||||||
stringifySearchValue,
|
stringifySearchValue,
|
||||||
|
matchesActivities,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
@@ -335,12 +336,16 @@ function createOpportunitiesFilters({
|
|||||||
|
|
||||||
function getSortedOpportunities() {
|
function getSortedOpportunities() {
|
||||||
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
|
||||||
|
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
|
||||||
|
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
|
||||||
|
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
|
||||||
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
|
||||||
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
|
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
|
||||||
&& matchesSelectedStatus(item.StatusCode)
|
&& matchesSelectedStatus(item.StatusCode)
|
||||||
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
|
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
|
||||||
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
|
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
|
||||||
&& matchesTableSearch(item, normalizedSearch);
|
&& matchesTableSearch(item, normalizedSearch)
|
||||||
|
&& matchesActivities(item);
|
||||||
});
|
});
|
||||||
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
|
||||||
|
|
||||||
@@ -391,6 +396,7 @@ function createOpportunitiesFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesSelectedStatus(statusCode) {
|
function matchesSelectedStatus(statusCode) {
|
||||||
|
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
|
||||||
if (statusCode === "WON_PENDING") {
|
if (statusCode === "WON_PENDING") {
|
||||||
return opportunitiesFilterState.selectedStatuses.has("WON");
|
return opportunitiesFilterState.selectedStatuses.has("WON");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
|
|||||||
|
|
||||||
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
|
||||||
const key = getOwnerKey(item);
|
const key = getOwnerKey(item);
|
||||||
const label = getOwnerName(item);
|
const label = String(getOwnerName(item)).trim() || "-";
|
||||||
|
|
||||||
if (key && label && !optionsByKey.has(key)) {
|
if (key && label && !optionsByKey.has(key)) {
|
||||||
optionsByKey.set(key, { key, label: String(label) });
|
optionsByKey.set(key, { key, label: String(label) });
|
||||||
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
|
|||||||
function getOwnerKey(item) {
|
function getOwnerKey(item) {
|
||||||
const owner = item.Owner;
|
const owner = item.Owner;
|
||||||
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
|
||||||
const name = getOwnerName(item);
|
const name = String(getOwnerName(item)).trim();
|
||||||
|
|
||||||
|
if (!name) return "owner:unassigned";
|
||||||
|
|
||||||
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
if (partyId !== null && partyId !== undefined && partyId !== "") {
|
||||||
return `party:${partyId}`;
|
return `party:${partyId}`;
|
||||||
|
|||||||
42
src/features/opportunities/product-activities.js
Normal file
42
src/features/opportunities/product-activities.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function createProductActivities({ productsContentState, opportunitiesQueryState,
|
||||||
|
loadCrm, loadActions, blockActions, render }) {
|
||||||
|
let cycle = null;
|
||||||
|
function begin(requestId, token) {
|
||||||
|
cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false };
|
||||||
|
}
|
||||||
|
function complete(requestId) {
|
||||||
|
const current = cycle;
|
||||||
|
if (!current || current.completed || current.requestId !== requestId
|
||||||
|
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||||
|
current.completed = true;
|
||||||
|
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
|
||||||
|
if (!products.length) return;
|
||||||
|
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||||
|
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
|
||||||
|
&& opportunitiesQueryState.periodRequestVersion === current.version;
|
||||||
|
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => {
|
||||||
|
if (!isActive()) return;
|
||||||
|
if (success === false) {
|
||||||
|
products.forEach(blockActions);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const result of products) {
|
||||||
|
const items = result.items.filter(item => {
|
||||||
|
const id = String(item?.RevnId ?? "");
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true }));
|
||||||
|
}
|
||||||
|
await Promise.allSettled(pending);
|
||||||
|
if (!isActive()) return;
|
||||||
|
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
|
||||||
|
render();
|
||||||
|
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
|
||||||
|
}
|
||||||
|
return { begin, complete };
|
||||||
|
}
|
||||||
49
src/features/opportunities/refresh-all.js
Normal file
49
src/features/opportunities/refresh-all.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||||
|
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||||
|
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||||
|
let pending = null;
|
||||||
|
function refresh(period) {
|
||||||
|
if (pending) return pending;
|
||||||
|
opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
productsContentState.currentProductsRequestId = "";
|
||||||
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
|
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
|
||||||
|
consumptionPlansState.accountPlansRequestState = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendCache = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||||
|
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||||
|
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||||
|
resetRelatedData();
|
||||||
|
pending = (async () => {
|
||||||
|
await activityCache.invalidate();
|
||||||
|
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
|
||||||
|
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
|
||||||
|
await reload(period);
|
||||||
|
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
function createButton(getPeriod) {
|
||||||
|
const button = environment.document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
|
||||||
|
button.append(createRefreshIcon());
|
||||||
|
button.title = "Refresh all";
|
||||||
|
button.setAttribute("aria-label", "Refresh all");
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (button.disabled) return;
|
||||||
|
button.disabled = true;
|
||||||
|
button.title = "Refreshing...";
|
||||||
|
button.setAttribute("aria-busy", "true");
|
||||||
|
try { await refresh(getPeriod()); }
|
||||||
|
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
|
||||||
|
finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.title = "Refresh all";
|
||||||
|
button.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
return { refresh, createButton };
|
||||||
|
}
|
||||||
@@ -9,14 +9,7 @@
|
|||||||
.opportunities-extension-auth-badge { transition: none; }
|
.opportunities-extension-auth-badge { transition: none; }
|
||||||
}
|
}
|
||||||
.opportunities-extension-crm-sr-badge {
|
.opportunities-extension-crm-sr-badge {
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #408254;
|
border: 1px solid #408254;
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #185b30;
|
color: #185b30;
|
||||||
background: #e1f1e5;
|
background: #e1f1e5;
|
||||||
}
|
}
|
||||||
@@ -31,14 +24,15 @@
|
|||||||
|
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;
|
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * {
|
.opportunities-extension-form > *,
|
||||||
|
.opportunities-extension-badge-filters > section {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -47,7 +41,8 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form > * > span:first-child {
|
.opportunities-extension-form > * > span:first-child,
|
||||||
|
.opportunities-extension-badge-filters > section > span:first-child {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
@@ -89,30 +84,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
.opportunities-extension-form .opportunities-extension-stage-filter-button,
|
||||||
.opportunities-extension-form .opportunities-extension-status-filter-button {
|
.opportunities-extension-form .opportunities-extension-status-filter-button,
|
||||||
|
.opportunities-extension-form .opportunities-extension-activity-filter-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 28px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native select intrinsic sizing includes its longest option, not just the selection. */
|
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
|
||||||
@media (min-width: 1151px) {
|
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
|
||||||
.opportunities-extension-form > .opportunities-extension-field,
|
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
|
||||||
.opportunities-extension-form .opportunities-extension-select-wrap,
|
display: inline-flex;
|
||||||
.opportunities-extension-form .opportunities-extension-field select {
|
align-items: center;
|
||||||
width: max-content;
|
justify-content: flex-start;
|
||||||
max-width: none;
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 4px;
|
||||||
|
height: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
|
||||||
|
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
|
||||||
|
display: block;
|
||||||
|
order: -1;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.25;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1150px) {
|
@media (max-width: 1150px) {
|
||||||
.opportunities-extension-form {
|
.opportunities-extension-form {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,16 +167,212 @@
|
|||||||
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
.opportunities-extension-form .opportunities-extension-status-filter-controls {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter {
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
.opportunities-extension-form > .opportunities-extension-status-filter,
|
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
|
||||||
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
|
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-badge-filters { display: contents; }
|
||||||
|
.opportunities-extension-activity-filter { display: grid; }
|
||||||
|
.opportunities-extension-activity-filter[hidden] { display: none; }
|
||||||
|
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
|
||||||
|
/* Flexible fields share the space left by the four compact badge groups. */
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-activity-filter-button {
|
||||||
|
min-width: 32px;
|
||||||
|
gap: 4px;
|
||||||
|
color: #185b30;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
|
||||||
|
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
|
||||||
|
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
|
||||||
|
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
|
||||||
|
@media (max-width: 1350px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.opportunities-extension-add-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
|
||||||
|
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-action-form-modal {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, .42);
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-dialog {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
color: #312d2a;
|
||||||
|
border: 1px solid #b9b4ae;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-context dt { font-weight: 600; }
|
||||||
|
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
|
||||||
|
.opportunities-extension-action-form-field[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
|
||||||
|
.opportunities-extension-action-form-wide,
|
||||||
|
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
|
||||||
|
.opportunities-extension-action-form-field input,
|
||||||
|
.opportunities-extension-action-form-field select,
|
||||||
|
.opportunities-extension-action-form-field textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 20px;
|
||||||
|
color: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
|
||||||
|
.opportunities-extension-action-form-field select { height: 38px; }
|
||||||
|
.opportunities-extension-action-form-field select[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||||
|
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
|
||||||
|
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
|
||||||
|
.opportunities-extension-action-combobox input { height: 38px; }
|
||||||
|
.opportunities-extension-action-options {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
||||||
|
background: #fff;
|
||||||
|
color: #292725;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
|
||||||
|
.opportunities-extension-action-options[hidden] { display: none; }
|
||||||
|
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
|
||||||
|
.opportunities-extension-action-option:hover,
|
||||||
|
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
|
||||||
|
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
|
||||||
|
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.opportunities-extension-action-form-footer button {
|
||||||
|
border: 1px solid #8f8983;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: #292725;
|
||||||
|
color: #eeeae6;
|
||||||
|
border-color: #756f68;
|
||||||
|
}
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep layout rules on the outer table, never on expanded product tables. */
|
/* Keep layout rules on the outer table, never on expanded product tables. */
|
||||||
@@ -232,18 +470,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.opportunities-extension-products-ramp {
|
.opportunities-extension-products-ramp {
|
||||||
display: inline-block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid #bba5cf;
|
border: 1px solid #bba5cf;
|
||||||
border-radius: 8px;
|
|
||||||
background: #f0e8f7;
|
background: #f0e8f7;
|
||||||
color: #63358a;
|
color: #63358a;
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -302,6 +533,11 @@
|
|||||||
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
|
||||||
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
|
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
|
||||||
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
|
||||||
|
.opportunities-extension-sr-children { margin: 10px 0 0 8px; padding-left: 10px; border-left: 2px solid #8c969a; overflow-wrap: anywhere; }
|
||||||
|
.opportunities-extension-sr-children[aria-busy="true"] { min-height: 28px; animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
|
||||||
|
.opportunities-extension-sr-children[role="alert"] { color: #a12d27; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-children[role="alert"] { color: #ffd9d5; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .opportunities-extension-sr-children[aria-busy="true"] { animation: none; } }
|
||||||
.opportunities-extension-sr-dismiss {
|
.opportunities-extension-sr-dismiss {
|
||||||
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
|
||||||
display: grid; place-items: center; padding: 4px; border: 0;
|
display: grid; place-items: center; padding: 4px; border: 0;
|
||||||
@@ -311,3 +547,41 @@
|
|||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
||||||
|
.opportunities-extension-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row h1 {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
|
||||||
|
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
|
||||||
|
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||||
|
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
|
||||||
|
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function createOpportunitiesTable({
|
|||||||
productsContentState,
|
productsContentState,
|
||||||
renderCustomerFilter,
|
renderCustomerFilter,
|
||||||
renderOwnerFilter,
|
renderOwnerFilter,
|
||||||
|
renderActivityFilters,
|
||||||
renderStageDashboard,
|
renderStageDashboard,
|
||||||
resetCustomerFilter,
|
resetCustomerFilter,
|
||||||
resetOwnerFilter,
|
resetOwnerFilter,
|
||||||
@@ -47,6 +48,7 @@ function createOpportunitiesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderOpportunitiesTable() {
|
function renderOpportunitiesTable() {
|
||||||
|
renderActivityFilters();
|
||||||
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
|
||||||
|
|
||||||
if (!table) {
|
if (!table) {
|
||||||
@@ -99,7 +101,7 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const tableBody = document.createElement("tbody");
|
const tableBody = document.createElement("tbody");
|
||||||
const items = getSortedOpportunities();
|
const items = getSortedOpportunities();
|
||||||
renderStageDashboard(items);
|
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
||||||
|
|
||||||
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
|
|||||||
407
src/features/opportunities/workload-action-form.js
Normal file
407
src/features/opportunities/workload-action-form.js
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
||||||
|
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
|
||||||
|
const { document } = environment;
|
||||||
|
let active = null;
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function searchableSelect(select, field, onChange) {
|
||||||
|
const box = element("div", "opportunities-extension-action-combobox");
|
||||||
|
const input = element("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.autocomplete = "off";
|
||||||
|
input.required = !!select.required;
|
||||||
|
input.setAttribute("aria-required", String(!!select.required));
|
||||||
|
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
|
||||||
|
input.id = `${select.id}-search`;
|
||||||
|
field.htmlFor = input.id;
|
||||||
|
const list = element("div", "opportunities-extension-action-options");
|
||||||
|
list.id = `${select.id}-options`;
|
||||||
|
list.setAttribute("role", "listbox");
|
||||||
|
input.setAttribute("role", "combobox");
|
||||||
|
input.setAttribute("aria-autocomplete", "list");
|
||||||
|
input.setAttribute("aria-controls", list.id);
|
||||||
|
input.setAttribute("aria-expanded", "false");
|
||||||
|
select.hidden = true;
|
||||||
|
select.tabIndex = -1;
|
||||||
|
box.append(input, list);
|
||||||
|
field.append(box);
|
||||||
|
let matches = [], index = -1;
|
||||||
|
const options = () => Array.from(select.children).filter(item => item.value);
|
||||||
|
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||||
|
function closeList() {
|
||||||
|
list.hidden = true;
|
||||||
|
input.setAttribute("aria-expanded", "false");
|
||||||
|
input.setAttribute("aria-activedescendant", "");
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
closeList();
|
||||||
|
input.disabled = !!select.disabled;
|
||||||
|
input.placeholder = select.children[0]?.textContent || "Select";
|
||||||
|
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
||||||
|
input.title = input.value;
|
||||||
|
}
|
||||||
|
function highlight() {
|
||||||
|
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
||||||
|
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
||||||
|
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
}
|
||||||
|
function choose(i) {
|
||||||
|
if (!matches[i]) return;
|
||||||
|
const changed = select.value !== matches[i].value;
|
||||||
|
select.value = matches[i].value;
|
||||||
|
sync();
|
||||||
|
if (changed) onChange?.();
|
||||||
|
}
|
||||||
|
function positionList() {
|
||||||
|
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||||
|
const anchor = input.getBoundingClientRect?.();
|
||||||
|
if (!bounds || !anchor) return;
|
||||||
|
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
|
||||||
|
const above = Math.max(0, anchor.top - bounds.top - 8);
|
||||||
|
const opensAbove = below < 180 && above > below;
|
||||||
|
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
||||||
|
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
|
||||||
|
}
|
||||||
|
function render(query = "") {
|
||||||
|
if (input.disabled) return;
|
||||||
|
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
|
||||||
|
index = -1;
|
||||||
|
list.textContent = "";
|
||||||
|
matches.forEach((item, i) => {
|
||||||
|
const row = element("div", "opportunities-extension-action-option", item.textContent);
|
||||||
|
row.id = `${list.id}-${i}`;
|
||||||
|
row.setAttribute("role", "option");
|
||||||
|
row.setAttribute("aria-selected", "false");
|
||||||
|
row.addEventListener("mousedown", event => event.preventDefault());
|
||||||
|
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
if (!matches.length) {
|
||||||
|
const empty = element("div", "opportunities-extension-action-option", "No results");
|
||||||
|
empty.setAttribute("role", "status");
|
||||||
|
list.append(empty);
|
||||||
|
}
|
||||||
|
positionList();
|
||||||
|
list.hidden = false;
|
||||||
|
input.setAttribute("aria-expanded", "true");
|
||||||
|
input.setAttribute("aria-activedescendant", "");
|
||||||
|
}
|
||||||
|
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||||
|
input.addEventListener("input", () => render(input.value));
|
||||||
|
input.addEventListener("blur", sync);
|
||||||
|
input.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (list.hidden) render();
|
||||||
|
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
|
||||||
|
highlight();
|
||||||
|
} else if (event.key === "Enter" && !list.hidden) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(index < 0 && matches.length === 1 ? 0 : index);
|
||||||
|
} else if (event.key === "Escape" && !list.hidden) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
sync();
|
||||||
|
} else if (event.key === "Tab") sync();
|
||||||
|
});
|
||||||
|
sync();
|
||||||
|
return { input, sync };
|
||||||
|
}
|
||||||
|
function close(restoreFocus = true) {
|
||||||
|
if (!active) return false;
|
||||||
|
if (active.saving) return true;
|
||||||
|
const previous = active;
|
||||||
|
active = null;
|
||||||
|
previous.modal.remove();
|
||||||
|
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createButton(optyNumber) {
|
||||||
|
const button = element("button", "opportunities-extension-add-action", "+");
|
||||||
|
button.type = "button";
|
||||||
|
button.title = "Add Action";
|
||||||
|
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
|
||||||
|
button.setAttribute("aria-haspopup", "dialog");
|
||||||
|
button.addEventListener("click", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
open(optyNumber, button);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
function open(optyNumber, trigger) {
|
||||||
|
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
|
||||||
|
const host = document.getElementById(MODAL_ID);
|
||||||
|
if (!host || host.hidden) return;
|
||||||
|
close(false);
|
||||||
|
const modal = element("section", "opportunities-extension-action-form-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
|
||||||
|
const dialog = element("div", "opportunities-extension-action-form-dialog");
|
||||||
|
const header = element("header", "opportunities-extension-account-plans-header");
|
||||||
|
const heading = element("div", "opportunities-extension-action-form-heading");
|
||||||
|
const title = element("h2", "", "Add Action");
|
||||||
|
title.id = "opportunities-extension-action-form-title";
|
||||||
|
heading.append(title, element("p", "", String(optyNumber)));
|
||||||
|
const opportunity = getOpportunity(optyNumber);
|
||||||
|
const details = element("dl", "opportunities-extension-action-form-context");
|
||||||
|
for (const [label, value] of [
|
||||||
|
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
|
||||||
|
["Name", opportunity?.Name],
|
||||||
|
["Close Date", formatDate(opportunity?.EffectiveDate)]
|
||||||
|
]) {
|
||||||
|
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
|
||||||
|
}
|
||||||
|
heading.append(details);
|
||||||
|
const dismiss = element("button", "opportunities-extension-icon-button");
|
||||||
|
dismiss.type = "button";
|
||||||
|
dismiss.title = "Close";
|
||||||
|
dismiss.setAttribute("aria-label", "Close Action form");
|
||||||
|
dismiss.append(createCloseIcon());
|
||||||
|
dismiss.addEventListener("click", () => close());
|
||||||
|
header.append(heading, dismiss);
|
||||||
|
const form = element("form", "opportunities-extension-action-form");
|
||||||
|
form.noValidate = true;
|
||||||
|
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
|
||||||
|
.map(product => [String(product.RevnId), product])).entries()];
|
||||||
|
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
|
||||||
|
const workload = element("select");
|
||||||
|
workload.id = "opportunities-extension-action-form-workload";
|
||||||
|
workloadField.htmlFor = workload.id;
|
||||||
|
function option(select, value, label) {
|
||||||
|
const item = element("option", "", label);
|
||||||
|
item.value = value;
|
||||||
|
select.append(item);
|
||||||
|
}
|
||||||
|
option(workload, "", "Select workload");
|
||||||
|
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
|
||||||
|
workload.value = workloads.length === 1 ? workloads[0][0] : "";
|
||||||
|
workloadField.hidden = workloads.length <= 1;
|
||||||
|
workloadField.append(element("span", "", "Workload"), workload);
|
||||||
|
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
form.append(workloadField, status);
|
||||||
|
const searchable = new Map();
|
||||||
|
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
|
||||||
|
const fields = [];
|
||||||
|
const selects = [];
|
||||||
|
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
|
||||||
|
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
|
||||||
|
for (const [name, label, type] of [
|
||||||
|
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
|
||||||
|
["team", "Team", "select"], ["startDate", "Start date", "date"],
|
||||||
|
["endDate", "End Date", "date"], ["status", "Status", "select"],
|
||||||
|
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
|
||||||
|
]) {
|
||||||
|
const field = element("label", "opportunities-extension-action-form-field");
|
||||||
|
const input = element(type === "select" || type === "textarea" ? type : "input");
|
||||||
|
if (type === "select") selects.push({ input, label, name });
|
||||||
|
else if (type === "textarea") input.rows = 5;
|
||||||
|
else input.type = type;
|
||||||
|
input.disabled = true;
|
||||||
|
input.required = name !== "notes";
|
||||||
|
input.setAttribute("aria-required", String(input.required));
|
||||||
|
input.name = name;
|
||||||
|
input.id = `opportunities-extension-action-form-${name}`;
|
||||||
|
field.htmlFor = input.id;
|
||||||
|
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
|
||||||
|
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
|
||||||
|
if (type === "select") searchable.set(input, searchableSelect(input, field));
|
||||||
|
form.append(field);
|
||||||
|
fields.push(input);
|
||||||
|
}
|
||||||
|
const footer = element("footer", "opportunities-extension-action-form-footer");
|
||||||
|
const save = element("button", "opportunities-extension-action-form-save", "Save");
|
||||||
|
save.type = "submit";
|
||||||
|
save.disabled = true;
|
||||||
|
save.title = "Save Action";
|
||||||
|
const retry = element("button", "", "Retry");
|
||||||
|
retry.type = "button";
|
||||||
|
retry.hidden = true;
|
||||||
|
const cancel = element("button", "", "Cancel");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.addEventListener("click", () => close());
|
||||||
|
footer.append(retry, save, cancel);
|
||||||
|
form.append(footer);
|
||||||
|
dialog.append(header, form);
|
||||||
|
modal.append(dialog);
|
||||||
|
host.append(modal);
|
||||||
|
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
|
||||||
|
active = view;
|
||||||
|
const isOpen = () => active === view && allowsCrmActivities();
|
||||||
|
let ready = false;
|
||||||
|
function validate() {
|
||||||
|
const invalid = [];
|
||||||
|
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
|
||||||
|
for (const field of fields) {
|
||||||
|
let valid = !field.required || !!value(field.name);
|
||||||
|
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
|
||||||
|
if (field.type === "date") {
|
||||||
|
const date = value(field.name);
|
||||||
|
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
|
||||||
|
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
|
||||||
|
if (field.name === "endDate" && value("startDate") > date) valid = false;
|
||||||
|
}
|
||||||
|
const control = searchable.get(field)?.input || field;
|
||||||
|
control.setAttribute("aria-invalid", String(!valid));
|
||||||
|
if (!valid) invalid.push(control);
|
||||||
|
}
|
||||||
|
return { invalid, value };
|
||||||
|
}
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ready || !isOpen() || view.saving) return;
|
||||||
|
const { invalid, value } = validate();
|
||||||
|
if (invalid.length) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
|
||||||
|
invalid[0].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getCurrentUserId();
|
||||||
|
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
|
||||||
|
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
|
||||||
|
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
|
||||||
|
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
|
||||||
|
objective: "", createdBy: userId, updatedBy: userId
|
||||||
|
} };
|
||||||
|
view.saving = true;
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
|
||||||
|
fields.forEach(field => { field.disabled = true; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Saving...";
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.hidden = true;
|
||||||
|
try {
|
||||||
|
const result = await saveAction(payload, isOpen);
|
||||||
|
if (!result) return;
|
||||||
|
view.saving = false;
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
|
||||||
|
} catch {
|
||||||
|
notify("Action saved, but the local badge could not be updated.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify("Action saved successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to save Action.";
|
||||||
|
notify(status.textContent, true);
|
||||||
|
} finally {
|
||||||
|
view.saving = false;
|
||||||
|
if (active === view) {
|
||||||
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
|
||||||
|
fields.forEach(field => { field.disabled = false; });
|
||||||
|
searchable.forEach(control => control.sync());
|
||||||
|
save.textContent = "Save";
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
|
||||||
|
async function loadOptions(refresh = false) {
|
||||||
|
const id = workload.value;
|
||||||
|
if (!isOpen() || (view.loadingId === id && !refresh)) return;
|
||||||
|
const generation = ++view.generation;
|
||||||
|
const isCurrent = () => isOpen() && view.generation === generation;
|
||||||
|
ready = false;
|
||||||
|
save.disabled = true;
|
||||||
|
retry.hidden = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
fields.forEach(input => { input.disabled = true; });
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
|
||||||
|
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
|
||||||
|
syncSearch();
|
||||||
|
if (!workloads.length) {
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = "No product with a valid RevnId is available for this opportunity.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
|
||||||
|
if (!workloads.some(([value]) => value === id)) {
|
||||||
|
view.loadingId = null;
|
||||||
|
form.setAttribute("data-state", "waiting");
|
||||||
|
form.setAttribute("aria-busy", "false");
|
||||||
|
status.textContent = "Select a workload to load Action options.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.loadingId = id;
|
||||||
|
form.setAttribute("data-state", "loading");
|
||||||
|
form.setAttribute("aria-busy", "true");
|
||||||
|
status.textContent = "Loading Action options...";
|
||||||
|
try {
|
||||||
|
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
if (lookup.error || team.error) throw lookup.error || team.error;
|
||||||
|
const values = selects.map(({ name }) => name === "owner"
|
||||||
|
? team.result.members.map(({ email }) => ({ code: email, name: email }))
|
||||||
|
: lookup.result.lookups[lookupKeys[name]]);
|
||||||
|
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
|
||||||
|
action: "Success Plan & Consumption review", status: "Complete" };
|
||||||
|
const normalize = value => String(value ?? "").trim().toLowerCase();
|
||||||
|
for (const [index, { input, label, name }] of selects.entries()) {
|
||||||
|
input.textContent = "";
|
||||||
|
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
|
||||||
|
for (const item of values[index]) option(input, item.code, item.name);
|
||||||
|
const match = defaults[name] && values[index].find(item =>
|
||||||
|
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
|
||||||
|
input.value = match ? match.code : "";
|
||||||
|
}
|
||||||
|
fields.forEach(input => { input.disabled = false; });
|
||||||
|
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
|
||||||
|
form.setAttribute("data-state", "ready");
|
||||||
|
ready = true;
|
||||||
|
save.disabled = false;
|
||||||
|
status.hidden = true;
|
||||||
|
syncSearch();
|
||||||
|
if (document.activeElement === dismiss) {
|
||||||
|
const first = fields.find(field => !field.disabled);
|
||||||
|
(searchable.get(first)?.input || first)?.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
form.setAttribute("data-state", "error");
|
||||||
|
status.setAttribute("role", "alert");
|
||||||
|
status.textContent = error.message || "Unable to load Action options.";
|
||||||
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
||||||
|
syncSearch();
|
||||||
|
retry.hidden = false;
|
||||||
|
} finally {
|
||||||
|
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
|
||||||
|
workload.addEventListener("change", () => loadOptions());
|
||||||
|
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
||||||
|
modal.addEventListener("keydown", event => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
|
||||||
|
.map(control => searchable.get(control)?.input || control)
|
||||||
|
.filter(control => !control.disabled && !control.hidden);
|
||||||
|
const index = controls.indexOf(document.activeElement);
|
||||||
|
if (!controls.length) { event.preventDefault(); return; }
|
||||||
|
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
||||||
|
event.preventDefault();
|
||||||
|
controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!workloadField.hidden) searchable.get(workload).input.focus();
|
||||||
|
else dismiss.focus();
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
return { createButton, open, close };
|
||||||
|
}
|
||||||
17
src/features/opportunities/workload-action-options.js
Normal file
17
src/features/opportunities/workload-action-options.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
|
async function request(type, workloadId, isCurrent, payload) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
const authorization = authorizations?.workloadActions;
|
||||||
|
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
|
||||||
|
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
|
||||||
|
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,31 +1,58 @@
|
|||||||
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
|
||||||
let session = newSession();
|
let session = newSession();
|
||||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
|
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||||
|
function updateProgress(current) {
|
||||||
|
if (current !== session || !current.enabled || !current.progress.size) return;
|
||||||
|
const records = [...current.progress];
|
||||||
|
const completed = records.filter(record => record.status !== "loading").length;
|
||||||
|
const failed = records.filter(record => record.status === "error").length;
|
||||||
|
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||||
|
}
|
||||||
function reset(enabled = true) {
|
function reset(enabled = true) {
|
||||||
|
closeActionForm(false);
|
||||||
session.enabled = false;
|
session.enabled = false;
|
||||||
|
for (const job of session.queue) job.record.resolveCompletion();
|
||||||
session.queue.length = 0;
|
session.queue.length = 0;
|
||||||
session = newSession();
|
session = newSession();
|
||||||
session.enabled = enabled;
|
session.enabled = enabled;
|
||||||
|
reportProgress("idle");
|
||||||
}
|
}
|
||||||
function idsFor(products) {
|
function idsFor(products) {
|
||||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
function loadProducts(products) {
|
function loadProducts(products, refresh = false, cacheOptions = null) {
|
||||||
if (!session.enabled || !allowsCrmActivities()) return;
|
if (!session.enabled || !allowsCrmActivities()) return;
|
||||||
|
const pending = [];
|
||||||
|
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
|
||||||
for (const revnId of idsFor(products)) {
|
for (const revnId of idsFor(products)) {
|
||||||
if (session.records.has(revnId)) continue;
|
if (!refresh && session.records.has(revnId)) {
|
||||||
const record = { status: "loading", actions: [], hasNext: false };
|
pending.push(session.records.get(revnId).completion);
|
||||||
session.records.set(revnId, record);
|
continue;
|
||||||
session.queue.push({ revnId, record });
|
|
||||||
}
|
}
|
||||||
|
session.progress.delete(session.records.get(revnId));
|
||||||
|
const record = { status: "loading", actions: [], hasNext: false };
|
||||||
|
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
|
||||||
|
pending.push(record.completion);
|
||||||
|
session.records.set(revnId, record);
|
||||||
|
if (!cacheOptions) session.progress.add(record);
|
||||||
|
session.queue.push({ revnId, record, cacheOptions });
|
||||||
|
}
|
||||||
|
updateProgress(session);
|
||||||
pump(session);
|
pump(session);
|
||||||
|
return Promise.all(pending);
|
||||||
}
|
}
|
||||||
function reopen() {
|
function reopen() {
|
||||||
reset();
|
reset();
|
||||||
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
}
|
}
|
||||||
|
function blockProducts(products) {
|
||||||
|
if (!allowsCrmActivities()) return;
|
||||||
|
for (const id of idsFor(products)) {
|
||||||
|
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
|
||||||
|
}
|
||||||
|
}
|
||||||
function pump(current) {
|
function pump(current) {
|
||||||
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
|
||||||
const job = current.queue.shift();
|
const job = current.queue.shift();
|
||||||
@@ -33,26 +60,48 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function run(current, { revnId, record }) {
|
async function run(current, { revnId, record, cacheOptions }) {
|
||||||
try {
|
try {
|
||||||
|
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
|
if (cached) {
|
||||||
|
record.actions = cached.items;
|
||||||
|
record.hasNext = cached.hasNext;
|
||||||
|
record.status = "ready";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
|
||||||
|
current.progress.add(record);
|
||||||
|
updateProgress(current);
|
||||||
|
let result;
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const authorizations = await requestConsumerTokenServiceOnce();
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||||
appendBackgroundRequestLog(result?.debugRequests);
|
appendBackgroundRequestLog(result?.debugRequests);
|
||||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 5) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
record.actions = result.actions;
|
record.actions = result.actions;
|
||||||
record.hasNext = result.hasNext;
|
record.hasNext = result.hasNext;
|
||||||
record.status = "ready";
|
record.status = "ready";
|
||||||
|
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!current.enabled || current !== session) return;
|
if (!current.enabled || current !== session) return;
|
||||||
record.status = "error";
|
record.status = "error";
|
||||||
record.error = error.message || "Unable to load workload actions.";
|
record.error = error.message || "Unable to load workload actions.";
|
||||||
} finally {
|
} finally {
|
||||||
if (current.enabled && current === session) queueProductsTableRender();
|
record.resolveCompletion();
|
||||||
|
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createBadge(optyNumber) {
|
function getSummary(optyNumber) {
|
||||||
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
|
||||||
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
if (!products) return null;
|
if (!products) return null;
|
||||||
@@ -63,12 +112,47 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|
||||||
|| records.some(record => record?.status === "error" || record?.hasNext);
|
|| records.some(record => record?.status === "error" || record?.hasNext);
|
||||||
const principal = authSessionState.principal;
|
const principal = authSessionState.principal;
|
||||||
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
|
const owned = Boolean(principal?.trim()) && actions.some(action => typeof action.owner === "string"
|
||||||
|
&& action.owner.trim().toLowerCase() === principal.trim().toLowerCase());
|
||||||
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
|
||||||
|
return { state, actions, records, principal, pending, uncertain };
|
||||||
|
}
|
||||||
|
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
|
||||||
|
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
|
||||||
|
const id = String(revnId);
|
||||||
|
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
|
||||||
|
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
|
||||||
|
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
|
||||||
|
&& (action.revenueId == null || String(action.revenueId) === id));
|
||||||
|
if (!saved.length) return false;
|
||||||
|
const previous = session.records.get(id);
|
||||||
|
const actions = [...(previous?.actions || [])];
|
||||||
|
for (const action of saved) {
|
||||||
|
// The POST can return a display name in owner, unlike the workload GET.
|
||||||
|
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
|
||||||
|
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
|
||||||
|
const normalized = { ...action, ownerEmail, owner: ownerEmail };
|
||||||
|
const index = actions.findIndex(item => String(item.id) === String(action.id));
|
||||||
|
if (index === -1) actions.push(normalized);
|
||||||
|
else actions[index] = { ...actions[index], ...normalized };
|
||||||
|
}
|
||||||
|
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
|
||||||
|
completion: Promise.resolve(), resolveCompletion() {} };
|
||||||
|
session.records.set(id, record);
|
||||||
|
previous?.resolveCompletion?.();
|
||||||
|
session.progress.delete(previous);
|
||||||
|
queueProductsTableRender();
|
||||||
|
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function createBadge(optyNumber) {
|
||||||
|
const summary = getSummary(optyNumber);
|
||||||
|
if (!summary) return null;
|
||||||
|
const { state, actions, records, principal, pending, uncertain } = summary;
|
||||||
const badge = environment.document.createElement("span");
|
const badge = environment.document.createElement("span");
|
||||||
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
|
||||||
badge.setAttribute("data-state", state);
|
badge.setAttribute("data-state", state);
|
||||||
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?";
|
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
|
||||||
badge.title = actions.length ? actions.map(action => [
|
badge.title = actions.length ? actions.map(action => [
|
||||||
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
||||||
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
||||||
@@ -81,7 +165,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
|
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
|
||||||
badge.tabIndex = 0;
|
badge.tabIndex = 0;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
|
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
return { reset, reopen, loadProducts, createBadge };
|
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
|
let generation = 0;
|
||||||
|
async function invalidate() {
|
||||||
|
generation += 1;
|
||||||
|
productsCacheState.inFlightProducts.clear();
|
||||||
|
productsCacheState.productsCachePromise = Promise.resolve({});
|
||||||
|
await saveProductsCache({});
|
||||||
|
}
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
|
const currentGeneration = generation;
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
|
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
|
||||||
@@ -24,15 +33,14 @@ function createProductsCache({
|
|||||||
})
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
if (generation === currentGeneration) {
|
||||||
fetchedAt,
|
cache[optyNumber] = { fetchedAt, items };
|
||||||
items
|
|
||||||
};
|
|
||||||
await saveProductsCache(cache).catch(() => {});
|
await saveProductsCache(cache).catch(() => {});
|
||||||
|
}
|
||||||
return { items, fetchedAt };
|
return { items, fetchedAt };
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,5 +86,5 @@ function createProductsCache({
|
|||||||
return productsCacheState.productsCacheWritePromise;
|
return productsCacheState.productsCacheWritePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ function createProductsContentJobs({
|
|||||||
preferencesState,
|
preferencesState,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
loadWorkloadActions,
|
beginProductActivities,
|
||||||
|
completeProductActivities,
|
||||||
sendRuntimeMessage,
|
sendRuntimeMessage,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
@@ -52,6 +53,7 @@ function createProductsContentJobs({
|
|||||||
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
productsContentState.currentProductsRequestId = requestId;
|
productsContentState.currentProductsRequestId = requestId;
|
||||||
|
beginProductActivities(requestId, token);
|
||||||
productsContentState.opptyProductsByNumber = new Map();
|
productsContentState.opptyProductsByNumber = new Map();
|
||||||
productsContentState.expandedProductOptyNumbers = new Set();
|
productsContentState.expandedProductOptyNumbers = new Set();
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -119,11 +121,6 @@ function createProductsContentJobs({
|
|||||||
cachedAt: Number(message.cachedAt) || 0,
|
cachedAt: Number(message.cachedAt) || 0,
|
||||||
error: message.error || ""
|
error: message.error || ""
|
||||||
});
|
});
|
||||||
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
|
|
||||||
productsContentState.productsLastCachedAt = Number(message.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
productsContentState.productsProgress = {
|
productsContentState.productsProgress = {
|
||||||
@@ -134,6 +131,7 @@ function createProductsContentJobs({
|
|||||||
cacheHits: Number(message.cacheHits) || 0
|
cacheHits: Number(message.cacheHits) || 0
|
||||||
};
|
};
|
||||||
renderProductsProgress();
|
renderProductsProgress();
|
||||||
|
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
|
||||||
queueProductsTableRender();
|
queueProductsTableRender();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
function createProductsDetails({
|
function createProductsDetails({
|
||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
|
setBadgeContent,
|
||||||
createAiSummaryAlert,
|
createAiSummaryAlert,
|
||||||
formatOracleResponseDate,
|
formatOracleResponseDate,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
@@ -17,7 +18,7 @@ function createProductsDetails({
|
|||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "opportunities-extension-products-ramp";
|
badge.className = "opportunities-extension-products-ramp";
|
||||||
badge.setAttribute("data-missing", String(values.includes("N/D")));
|
badge.setAttribute("data-missing", String(values.includes("N/D")));
|
||||||
badge.textContent = `RM ${values.join(",")}`;
|
setBadgeContent(badge, "ramp", values.join(","));
|
||||||
badge.title = `Ramp Months: ${values.join(", ")}`;
|
badge.title = `Ramp Months: ${values.join(", ")}`;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
return badge;
|
return badge;
|
||||||
@@ -31,7 +32,7 @@ function createProductsDetails({
|
|||||||
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "opportunities-extension-products-count";
|
button.className = "opportunities-extension-products-count";
|
||||||
button.textContent = String(count);
|
setBadgeContent(button, "products", count);
|
||||||
button.setAttribute("aria-expanded", String(isExpanded));
|
button.setAttribute("aria-expanded", String(isExpanded));
|
||||||
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
|
||||||
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
try {
|
try {
|
||||||
const planNumber = String(plan.subPlanNum ?? "").trim();
|
const planNumber = String(plan.subPlanNum ?? "").trim();
|
||||||
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
|
||||||
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
|
if (!pending.has(planNumber)) {
|
||||||
|
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
|
||||||
|
pending.set(planNumber, promise);
|
||||||
|
}
|
||||||
handle.showItems(await pending.get(planNumber));
|
handle.showItems(await pending.get(planNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.showError(error.message || "Unable to load rate card.");
|
handle.showError(error.message || "Unable to load rate card.");
|
||||||
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
|
|||||||
button.addEventListener("click", () => showRatecard(plan, button));
|
button.addEventListener("click", () => showRatecard(plan, button));
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createRatecardButton, showRatecard };
|
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
try {
|
try {
|
||||||
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
|
||||||
if (!pending.has(partyNumber)) {
|
if (!pending.has(partyNumber)) {
|
||||||
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
|
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
|
||||||
|
pending.set(partyNumber, request);
|
||||||
}
|
}
|
||||||
view.render(await pending.get(partyNumber));
|
view.render(await pending.get(partyNumber));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
|
|||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return { createServiceRequestsButton };
|
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
|
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
|
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
|
||||||
const items = [];
|
const items = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
let attempt = 1;
|
||||||
while (isActive()) {
|
while (isActive()) {
|
||||||
const payload = createPayload(offset);
|
const payload = createPayload(offset);
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -28,30 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
|
|||||||
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
|
||||||
};
|
};
|
||||||
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
|
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
|
||||||
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } });
|
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
|
||||||
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`);
|
if (response.status !== 200) throw new Error(`CRM service request query failed with status ${response.status}.`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
|
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
|
||||||
throw new Error("CRM service request query returned an invalid response.");
|
throw new Error("CRM service request query returned an invalid response.");
|
||||||
}
|
}
|
||||||
if (!isActive()) return null;
|
if (!isActive()) return null;
|
||||||
items.push(...data.items);
|
|
||||||
if (!data.hasMore) return items;
|
|
||||||
const received = data.count === undefined ? data.items.length : Number(data.count);
|
const received = data.count === undefined ? data.items.length : Number(data.count);
|
||||||
const current = data.offset === undefined ? offset : Number(data.offset);
|
const current = data.offset === undefined ? offset : Number(data.offset);
|
||||||
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|
||||||
|| current !== offset || current + received <= offset) {
|
|| current !== offset || current + received <= offset)) {
|
||||||
throw new Error("CRM service request pagination did not advance.");
|
throw new Error("CRM service request pagination did not advance.");
|
||||||
}
|
}
|
||||||
|
items.push(...data.items);
|
||||||
|
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
|
||||||
|
if (!data.hasMore) return items;
|
||||||
offset = current + received;
|
offset = current + received;
|
||||||
|
attempt = 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(entry, error.message || "CRM service request query failed.");
|
logRequestFailure(entry, error.message || "CRM service request query failed.");
|
||||||
throw error;
|
if (attempt === 5) throw error;
|
||||||
|
attempt += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
27
src/services/fusion/sr-details.js
Normal file
27
src/services/fusion/sr-details.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
function createFusionSrDetails({ fetchJson }) {
|
||||||
|
async function requestInternalServiceRequest(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const id = String(message.srId ?? "");
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
|
||||||
|
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
|
||||||
|
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
|
||||||
|
const result = await fetchJson(url, { method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", Authorization: "Session" }
|
||||||
|
}, "requestInternalServiceRequest", debugRequests);
|
||||||
|
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
|
||||||
|
const items = result.items.flatMap(item => {
|
||||||
|
const children = item.ServiceRequest_SRToInternalSR_Tgt;
|
||||||
|
if (children == null) return [];
|
||||||
|
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
|
||||||
|
return children;
|
||||||
|
});
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestInternalServiceRequest };
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ function createHttpBackgroundJson({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchJson(url, options, requestName, debugRequests = null) {
|
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
|
||||||
let response;
|
let response;
|
||||||
@@ -25,7 +25,10 @@ function createHttpBackgroundJson({
|
|||||||
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
appendBackgroundDebugEntry(debugRequests, debugEntry);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${requestName} failed with status ${response.status}.`);
|
const error = new Error(`${requestName} failed with status ${response.status}.`);
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
debugEntry.error = error.message;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responsePayload.error) {
|
if (responsePayload.error) {
|
||||||
@@ -33,6 +36,7 @@ function createHttpBackgroundJson({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (allowEmpty && !responsePayload.text.trim()) return null;
|
||||||
return parseJsonResponse(responsePayload.text, requestName);
|
return parseJsonResponse(responsePayload.text, requestName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
error.httpStatus = response.status;
|
error.httpStatus = response.status;
|
||||||
|
|||||||
83
src/services/spa/action-options.js
Normal file
83
src/services/spa/action-options.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ function createMessageTypes() {
|
|||||||
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
requestInstallBase: "opportunitiesExtension.requestInstallBase",
|
||||||
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
|
||||||
requestWlActions: "opportunitiesExtension.requestWlActions",
|
requestWlActions: "opportunitiesExtension.requestWlActions",
|
||||||
|
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
|
||||||
|
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
|
||||||
|
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
|
||||||
|
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
|
||||||
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
|
||||||
|
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
|
||||||
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
|
||||||
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
|
||||||
|
|||||||
110
tests/actionOptions.test.cjs
Normal file
110
tests/actionOptions.test.cjs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const lookups = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
|
||||||
|
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} label` }]
|
||||||
|
]));
|
||||||
|
const teamType = "opportunitiesExtension.requestActionTeamMembers";
|
||||||
|
const lookupType = "opportunitiesExtension.requestActionFindLookup";
|
||||||
|
|
||||||
|
test("Save sends the WWB POST, accepts empty success and never retries errors", async () => {
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: "123", action: {
|
||||||
|
ownerEmail: "owner@example.invalid", role: "CA", name: "REVIEW", team: "TEAM", tags: "",
|
||||||
|
startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
status: "COMPLETE", complexity: "LOW", notes: [], objective: "",
|
||||||
|
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
|
||||||
|
} };
|
||||||
|
for (const status of [200, 201, 204, 403, 500]) {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family: "chrome", fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return status === 204 ? new Response(null, { status }) : response({ id: 123 }, status);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestMgmtAction", payload, authorization: { value: "secret-wwb" } });
|
||||||
|
assert.equal(result.ok, status < 300, `${status}: ${result.error}`);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(calls[0].url.endsWith("/wwb/mgmt/actions"));
|
||||||
|
assert.equal(calls[0].options.method, "POST");
|
||||||
|
assert.equal(calls[0].options.headers["Wwb-Provider-Authorization"], "secret-wwb");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret-wwb"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Action options use dynamic RevnId, WWB-TS and masked GET 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: { value: "summary-token" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "wrong-token" } },
|
||||||
|
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-wwb" } }
|
||||||
|
]);
|
||||||
|
return response(url.includes("teamMembers") ? [{ email: "owner@example.invalid" }] : lookups());
|
||||||
|
} });
|
||||||
|
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
const authorization = auth.authorizations.workloadActions;
|
||||||
|
const team = await h.dispatch({ type: teamType, workloadId: "987654", authorization, url: "https://example.invalid" });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization });
|
||||||
|
assert.equal(team.ok, true);
|
||||||
|
assert.deepEqual(team.members, [{ email: "owner@example.invalid" }]);
|
||||||
|
assert.deepEqual(lookup.lookups, lookups());
|
||||||
|
const base = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
assert.equal(calls[1].url, `${base}/987654/teamMembers?includeLeads=true`);
|
||||||
|
assert.equal(calls[2].url, `${base}/fndLookup`);
|
||||||
|
for (const call of calls.slice(1)) {
|
||||||
|
assert.equal(call.options.method, "GET");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
assert.deepEqual({ ...call.options.headers }, { Accept: "application/json", "Wwb-Provider-Authorization": "synthetic-wwb" });
|
||||||
|
}
|
||||||
|
for (const [result, name] of [[team, "requestActionTeamMembers"], [lookup, "requestActionFindLookup"]]) {
|
||||||
|
const log = result.debugRequests[0];
|
||||||
|
assert.equal(log.name, name);
|
||||||
|
assert.equal(log.status, "success");
|
||||||
|
assert.equal(log.httpStatus, 200);
|
||||||
|
assert.ok(log.durationMs >= 0);
|
||||||
|
assert.equal(log.requestHeaders["Wwb-Provider-Authorization"], "(present)");
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-wwb|wrong-token/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invalid workload IDs and missing authorization never fetch or accept an arbitrary target", async () => {
|
||||||
|
const h = loadBackground({ fetch: () => assert.fail("No network expected") });
|
||||||
|
for (const workloadId of [undefined, "", "abc", "12/../fndLookup", "12?other=1"]) {
|
||||||
|
assert.equal((await h.dispatch({ type: teamType, workloadId, authorization: { value: "synthetic" } })).ok, false);
|
||||||
|
}
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
assert.equal((await h.dispatch({ type, workloadId: "123" })).ok, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action options require exactly HTTP 200 and valid collections and preserve failed debug entries", async () => {
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
const valid = type === teamType ? [{ email: "owner@example.invalid" }] : lookups();
|
||||||
|
for (const fetch of [
|
||||||
|
async () => response(valid, 201), async () => response({}, 401), async () => response({}, 403),
|
||||||
|
async () => { throw new Error("Synthetic network failure"); },
|
||||||
|
async () => new Response("<html>Unexpected session page</html>"),
|
||||||
|
async () => response(type === teamType ? [{ name: "Missing email" }] : { ...lookups(), WWB_ACTION_NAME: null })
|
||||||
|
]) {
|
||||||
|
const h = loadBackground({ fetch });
|
||||||
|
const result = await h.dispatch({ type, workloadId: "123", authorization: { value: "synthetic-secret" } });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(result.debugRequests[0].status, "failed");
|
||||||
|
assert.ok(result.error);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty option lists are valid responses, not invented defaults", async () => {
|
||||||
|
const empty = Object.fromEntries(Object.keys(lookups()).map(key => [key, []]));
|
||||||
|
const h = loadBackground({ fetch: async url => response(url.includes("teamMembers") ? [] : empty) });
|
||||||
|
const members = await h.dispatch({ type: teamType, workloadId: "123", authorization: { value: "synthetic" } });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization: { value: "synthetic" } });
|
||||||
|
assert.deepEqual(members.members, []);
|
||||||
|
assert.deepEqual(lookup.lookups, empty);
|
||||||
|
});
|
||||||
144
tests/activityCache.test.cjs
Normal file
144
tests/activityCache.test.cjs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
function setup(family, saved = {}, principal = "user@example.invalid") {
|
||||||
|
const local = {
|
||||||
|
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
|
||||||
|
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
|
||||||
|
remove: async keys => keys.forEach(key => delete saved[key])
|
||||||
|
};
|
||||||
|
const api = family === "firefox" ? { browser: { storage: { local } } } : {
|
||||||
|
chrome: { runtime: {}, storage: { local: {
|
||||||
|
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
|
||||||
|
remove: (keys, done) => local.remove(keys).then(done)
|
||||||
|
} } }
|
||||||
|
};
|
||||||
|
const document = { createElement: () => ({ append() {}, setAttribute() {}, addEventListener() {} }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
|
const app = loadApplication("content", { ...api, document });
|
||||||
|
app.authSessionState.principal = principal;
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.preferencesState.opptyProductsCacheHours = 24;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-token" } });
|
||||||
|
const calls = [];
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||||
|
calls.push("sr");
|
||||||
|
return [{ SrNumber: "SR1", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
};
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => {
|
||||||
|
calls.push("actions");
|
||||||
|
return { ok: true, actions: [{ owner: principal, name: "Test action" }], hasNext: false };
|
||||||
|
};
|
||||||
|
async function load(cachedAt, cacheOnly) {
|
||||||
|
app.opportunityCrmServiceRequests.reset();
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic-token", 1, { cachedAt, cacheOnly });
|
||||||
|
const products = { items: [{ RevnId: "123" }], cachedAt, fromCache: cacheOnly };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("OP1", products);
|
||||||
|
app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt, cacheOnly });
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
return { app, calls, load, saved, local };
|
||||||
|
}
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
|
||||||
|
const h = setup(family);
|
||||||
|
const currentStamp = Date.now() - 2000;
|
||||||
|
const nextStamp = currentStamp + 1000;
|
||||||
|
let hours = 2;
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
|
||||||
|
h.calls.push("sr");
|
||||||
|
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
};
|
||||||
|
await h.load(currentStamp, false);
|
||||||
|
hours = 8;
|
||||||
|
await h.load(nextStamp, false);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
|
||||||
|
const count = h.calls.length;
|
||||||
|
await h.load(currentStamp, true);
|
||||||
|
assert.equal(h.calls.length, count);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "2h");
|
||||||
|
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
h.app.activityFilters.toggle("sr", "under4");
|
||||||
|
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
|
||||||
|
await h.load(nextStamp, true);
|
||||||
|
assert.equal(h.calls.length, count);
|
||||||
|
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
|
||||||
|
const reopened = setup(family, h.saved);
|
||||||
|
await reopened.load(currentStamp, true);
|
||||||
|
assert.deepEqual(reopened.calls, []);
|
||||||
|
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
|
||||||
|
});
|
||||||
|
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
|
||||||
|
const h = setup(family);
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
await h.load(stamp, false);
|
||||||
|
assert.deepEqual(h.calls, ["sr", "actions"]);
|
||||||
|
assert.equal(Object.keys(h.saved).length, 2);
|
||||||
|
Object.values(h.saved).forEach(entry => assert.equal(entry.fetchedAt, stamp));
|
||||||
|
assert.doesNotMatch(JSON.stringify(h.saved), /synthetic-token|debugRequests|authorization/i);
|
||||||
|
const restored = setup(family, h.saved);
|
||||||
|
await restored.load(stamp, true);
|
||||||
|
assert.deepEqual(restored.calls, []);
|
||||||
|
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
|
||||||
|
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "1");
|
||||||
|
await restored.load(stamp, false);
|
||||||
|
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
|
||||||
|
const other = setup(family, h.saved, "other@example.invalid");
|
||||||
|
await other.load(stamp, true);
|
||||||
|
assert.deepEqual(other.calls, []);
|
||||||
|
assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null);
|
||||||
|
assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "?");
|
||||||
|
await restored.load(stamp + 1, false);
|
||||||
|
assert.deepEqual(restored.calls, ["sr", "actions"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("TTL follows product preferences, rejects stale/mismatched cache and stores empty success", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
const stamp = Date.now() - 2 * 3600000;
|
||||||
|
await h.app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||||
|
h.app.preferencesState.opptyProductsCacheHours = 1;
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp), null);
|
||||||
|
h.app.preferencesState.opptyProductsCacheHours = 24;
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", Date.now() + 100000), null);
|
||||||
|
await h.load(stamp + 1, true);
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
|
||||||
|
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
const expiredStamp = Date.now() - 25 * 3600000;
|
||||||
|
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
|
||||||
|
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
|
||||||
|
h.saved.preferences = { keep: true };
|
||||||
|
await h.app.activityCache.write("sr", "all", stamp + 1, []);
|
||||||
|
assert.equal(h.saved[expiredKey], undefined);
|
||||||
|
assert.ok(h.saved[legacyKey]);
|
||||||
|
assert.equal(h.saved.preferences.keep, true);
|
||||||
|
await h.app.activityCache.invalidate();
|
||||||
|
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
|
||||||
|
assert.equal(h.saved.preferences.keep, true);
|
||||||
|
});
|
||||||
|
test("storage failure does not lose live activity results and failures are never cached", async () => {
|
||||||
|
const h = setup("firefox");
|
||||||
|
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
|
||||||
|
h.local.set = async () => { throw new Error("Synthetic storage write failure"); };
|
||||||
|
await h.load(Date.now() - 1000, false);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
|
||||||
|
const failed = setup("firefox");
|
||||||
|
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
|
||||||
|
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });
|
||||||
|
await failed.load(Date.now() - 1000, false);
|
||||||
|
assert.deepEqual(failed.saved, {});
|
||||||
|
});
|
||||||
254
tests/activityFilters.test.cjs
Normal file
254
tests/activityFilters.test.cjs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
|
||||||
|
append(...nodes) { this.children.push(...nodes); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, handler) { this.listeners[key] = handler; }
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
let renders = 0;
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
|
||||||
|
const sr = new Map(), actions = new Map();
|
||||||
|
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
|
||||||
|
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
|
||||||
|
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
|
||||||
|
const controls = app.activityFilters.createControls(form, status, stage);
|
||||||
|
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
|
||||||
|
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
|
||||||
|
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
|
||||||
|
matches: number => app.activityFilters.matches({ OptyNumber: number }),
|
||||||
|
click: (kind, value) => button(kind, value).listeners.click() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("activity badge filters precede Status, are accessible and combine without network", () => {
|
||||||
|
const h = setup();
|
||||||
|
assert.equal(h.controls.children[2], h.status);
|
||||||
|
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
|
||||||
|
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
|
||||||
|
assert.equal(h.controls.children[3], h.stage);
|
||||||
|
assert.equal(h.form.attributes["data-activity-filters"], "true");
|
||||||
|
assert.equal(h.button("sr", "matched").textContent, "");
|
||||||
|
assert.equal(h.button("actions", "missing").textContent, "");
|
||||||
|
assert.equal(h.button("actions", "matched").type, "button");
|
||||||
|
assert.equal(h.controls.children[1].children[0].textContent, "WL Action");
|
||||||
|
for (const value of ["matched", "missing"]) {
|
||||||
|
assert.equal(h.button("actions", value).textContent, "");
|
||||||
|
assert.ok(h.button("actions", value).title.startsWith("Whether the Action is registered in Workload Workbench.\n"));
|
||||||
|
assert.ok(h.button("sr", value).title.startsWith("Whether a Service Request has been created for the opportunities.\n"));
|
||||||
|
for (const kind of ["sr", "actions"]) {
|
||||||
|
const button = h.button(kind, value);
|
||||||
|
assert.ok(button.title.endsWith(button.attributes["aria-label"]));
|
||||||
|
assert.equal(button.children.length, 1);
|
||||||
|
assert.equal(button.children[0].tag, "svg");
|
||||||
|
assert.equal(button.children[0].attributes["data-badge-icon"], kind);
|
||||||
|
assert.equal(button.children[0].attributes.stroke, "currentColor");
|
||||||
|
assert.equal(button.children[0].attributes["aria-hidden"], "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(h.button("sr", "under4").textContent, "< 4h");
|
||||||
|
assert.equal(h.button("sr", "under4").children[0].attributes["data-badge-icon"], "sr");
|
||||||
|
assert.notEqual(h.button("actions", "matched").attributes["aria-label"], h.button("actions", "missing").attributes["aria-label"]);
|
||||||
|
assert.notEqual(h.button("sr", "matched").attributes["aria-label"], h.button("sr", "missing").attributes["aria-label"]);
|
||||||
|
h.sr.set("A", { state: "matched" });
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
h.sr.set("B", { state: "missing" });
|
||||||
|
h.actions.set("B", { state: "matched" });
|
||||||
|
assert.equal(h.matches("unknown"), true);
|
||||||
|
h.click("sr", "missing");
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.matches("B"), false);
|
||||||
|
assert.equal(h.matches("unknown"), false);
|
||||||
|
h.click("actions", "missing");
|
||||||
|
assert.equal(h.matches("A"), false, "SR and Action use AND");
|
||||||
|
h.actions.set("A", { state: "matched" });
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.renders(), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SR below four hours is exclusive and regular SR options can replace it", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
|
||||||
|
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
|
||||||
|
h.click("sr", "under4");
|
||||||
|
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
assert.equal(h.matches("B"), false);
|
||||||
|
h.click("sr", "matched");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.matches("B"), true);
|
||||||
|
h.click("sr", "matched");
|
||||||
|
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
|
||||||
|
h.click("sr", "under4");
|
||||||
|
h.click("sr", "missing");
|
||||||
|
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter vectors retain their fixed slot without duplication when selection changes", () => {
|
||||||
|
const h = setup();
|
||||||
|
const options = [["sr", "matched"], ["sr", "under4"], ["sr", "missing"], ["actions", "matched"], ["actions", "missing"]];
|
||||||
|
const icons = options.map(([kind, value]) => h.button(kind, value).children[0]);
|
||||||
|
for (const [kind, value] of options) h.click(kind, value);
|
||||||
|
h.app.activityFilters.render();
|
||||||
|
options.forEach(([kind, value], index) => {
|
||||||
|
const button = h.button(kind, value);
|
||||||
|
assert.deepEqual(button.children, [icons[index]]);
|
||||||
|
assert.equal(icons[index].attributes.viewBox, "0 0 24 24");
|
||||||
|
assert.equal(icons[index].attributes.width, "16");
|
||||||
|
assert.equal(icons[index].attributes.height, "16");
|
||||||
|
assert.equal(icons[index].attributes["stroke-width"], "2.25");
|
||||||
|
assert.equal(icons[index].attributes.focusable, "false");
|
||||||
|
});
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-cell-actions \.opportunities-extension-badge-icon,\s*\.opportunities-extension-activity-filter-button \.opportunities-extension-badge-icon \{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "filters and opportunity badges share the same vector positioning");
|
||||||
|
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "stroke: currentColor;"]) {
|
||||||
|
assert.ok(shared[1].includes(rule), rule);
|
||||||
|
}
|
||||||
|
assert.match(css, /activity-filter-button \{\s*min-width: 32px;\s*gap: 4px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.click("sr", "matched");
|
||||||
|
h.click("actions", "matched");
|
||||||
|
for (const state of ["idle", "loading", "error", "matched"]) {
|
||||||
|
h.sr.set("A", { state });
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
h.sr.set("A", { state: "missing" });
|
||||||
|
h.actions.set("A", { state });
|
||||||
|
assert.equal(h.matches("A"), false);
|
||||||
|
}
|
||||||
|
h.actions.set("A", { state: "missing" });
|
||||||
|
assert.equal(h.matches("A"), true);
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
h.app.activityFilters.render();
|
||||||
|
assert.equal(h.matches("unknown"), true);
|
||||||
|
assert.equal(h.controls.children[0].hidden, true);
|
||||||
|
assert.equal(h.controls.children[1].hidden, true);
|
||||||
|
assert.equal(h.form.attributes["data-activity-filters"], "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
|
||||||
|
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
const calls = [];
|
||||||
|
let finish;
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||||
|
calls.push(message.type);
|
||||||
|
return new Promise(resolve => { finish = resolve; });
|
||||||
|
};
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
const form = new Element("form");
|
||||||
|
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
|
||||||
|
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
|
||||||
|
assert.equal(controls.children[1].hidden, true);
|
||||||
|
const identity = app.currentUser.ensure();
|
||||||
|
finish({ ok: true, user: { roles } });
|
||||||
|
await identity;
|
||||||
|
app.activityFilters.render();
|
||||||
|
assert.equal(controls.children[0].hidden, !allowed);
|
||||||
|
assert.equal(controls.children[1].hidden, !allowed);
|
||||||
|
if (!allowed) {
|
||||||
|
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
|
||||||
|
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
|
||||||
|
app.activityFilters.toggle("sr", "under4");
|
||||||
|
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||||
|
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
|
||||||
|
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
|
||||||
|
];
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
for (const [index, [, expected]] of examples.entries()) {
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
|
||||||
|
}
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.authSessionState.principal = "me@example.invalid";
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
|
||||||
|
const products = { items: [{ RevnId: "123" }] };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
|
await app.opportunityWorkloadActions.loadProducts(products);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
|
||||||
|
app.authSessionState.principal = "other@example.invalid";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
app.authSessionState.principal = "";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity filters compose with existing search, status, owner and customer filters", () => {
|
||||||
|
const h = setup();
|
||||||
|
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
|
||||||
|
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
h.app.opportunitiesFilters.resetCustomerFilter(items);
|
||||||
|
h.app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
|
h.sr.set("A", { state: "matched" });
|
||||||
|
h.sr.set("B", { state: "missing" });
|
||||||
|
h.click("sr", "missing");
|
||||||
|
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
|
||||||
|
assert.deepEqual(results(), ["A"]);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "Test B";
|
||||||
|
assert.deepEqual(results(), []);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "";
|
||||||
|
h.app.opportunitiesFilterState.selectedStatuses.clear();
|
||||||
|
assert.deepEqual(results(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
assert.match(css, /activity-filter-button:focus-visible/);
|
||||||
|
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
|
||||||
|
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
|
||||||
|
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SR and Act filters share sizing and typography with Status and Stage", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-form \.opportunities-extension-stage-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-status-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-activity-filter-button\s*\{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "all filter badges use the same presentation rule");
|
||||||
|
for (const declaration of ["display: inline-flex;", "align-items: center;", "box-sizing: border-box;", "height: 28px;",
|
||||||
|
"padding: 4px 7px;", "border-radius: 18px;", "font-size: 11px;", "font-weight: 700;", "line-height: 18px;"]) {
|
||||||
|
assert.ok(shared[1].includes(declaration), declaration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
|
||||||
|
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
|
||||||
|
const breakpoint = css.indexOf("@media (max-width: 1350px)");
|
||||||
|
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
|
||||||
|
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
|
||||||
|
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
|
||||||
|
});
|
||||||
64
tests/activityProgress.test.cjs
Normal file
64
tests/activityProgress.test.cjs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
test("activity indicators share Products styles, fade after 5s, retain failures and cancel stale timers", () => {
|
||||||
|
const nodes = [];
|
||||||
|
const timers = new Map();
|
||||||
|
let id = 0;
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: { querySelector: () => ({ append: node => nodes.push(node) }), createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) },
|
||||||
|
setTimeout: (fn, delay) => { timers.set(++id, { fn, delay }); return id; }, clearTimeout: key => timers.delete(key)
|
||||||
|
});
|
||||||
|
const update = app.activityProgress.update;
|
||||||
|
update("sr", "running", 15, 58);
|
||||||
|
assert.equal(nodes[0].textContent, "Service Requests 15/58");
|
||||||
|
assert.equal(nodes[0].attributes["data-state"], "running");
|
||||||
|
assert.equal(nodes[0].className, "opportunities-extension-products-progress");
|
||||||
|
update("sr", "complete", 58, 58);
|
||||||
|
assert.equal([...timers.values()][0].delay, 5000);
|
||||||
|
[...timers.values()][0].fn();
|
||||||
|
assert.equal(nodes[0].attributes["data-fading"], "true");
|
||||||
|
const hide = [...timers.values()].find(timer => timer.delay === 300);
|
||||||
|
hide.fn();
|
||||||
|
assert.equal(nodes[0].hidden, true);
|
||||||
|
update("sr", "running", 0);
|
||||||
|
assert.equal(nodes[0].hidden, false);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
update("actions", "complete-with-errors", 2, 3);
|
||||||
|
assert.equal(nodes[1].hidden, false);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
update("sr", "error");
|
||||||
|
assert.equal(nodes[0].textContent, "Service Requests unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const progress = [];
|
||||||
|
app.activityProgress.update = (...args) => progress.push(args);
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async (_token, _version, _active, onProgress) => {
|
||||||
|
onProgress(15, 20);
|
||||||
|
onProgress(20, 20);
|
||||||
|
return Array.from({ length: 20 }, () => ({}));
|
||||||
|
};
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.deepEqual(progress, [["sr", "running", 0], ["sr", "running", 15, 20], ["sr", "running", 20, 20], ["sr", "complete", 20, 20]]);
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({});
|
||||||
|
const pending = [];
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = () => new Promise(resolve => pending.push(resolve));
|
||||||
|
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "1" }, { RevnId: "2" }] });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 0, 2]);
|
||||||
|
pending[0]({ ok: true, actions: [], hasNext: false });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
pending[attempt]({ ok: false, error: "Synthetic failure" });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
|
||||||
|
});
|
||||||
108
tests/activityRetries.test.cjs
Normal file
108
tests/activityRetries.test.cjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const { response } = require("./support/characterization.cjs");
|
||||||
|
|
||||||
|
function setup(fetch) {
|
||||||
|
const app = loadApplication("content", { fetch });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.opportunitiesQueryState.periodRequestVersion = 1;
|
||||||
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.productsContentJobs.renderProductsProgress = () => {};
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
const actions = [];
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
actions.push(message);
|
||||||
|
return { ok: true, actions: [], hasNext: false };
|
||||||
|
};
|
||||||
|
async function cycle(id) {
|
||||||
|
app.productsContentState.currentProductsRequestId = id;
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }], cachedAt: Date.now(), fromCache: false });
|
||||||
|
app.productActivities.begin(id, "synthetic");
|
||||||
|
await app.productActivities.complete(id);
|
||||||
|
}
|
||||||
|
return { app, actions, cycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SR succeeds on fifth attempt, is shared in flight and reused across Reference/product reloads", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = setup(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls < 5) throw new Error("Synthetic offline");
|
||||||
|
return response({ items: [], hasMore: false });
|
||||||
|
});
|
||||||
|
const first = h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.load("synthetic", 1), first);
|
||||||
|
assert.equal(await first, true);
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
await h.cycle("current-quarter");
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
await h.cycle("next-quarter");
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
assert.equal(h.actions.length, 2);
|
||||||
|
assert.equal(h.app.debugState.requestLog.filter(entry => entry.name === "resquestCrmServiceRequest").length, 5);
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
await h.cycle("explicit-refresh");
|
||||||
|
assert.equal(calls, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [() => new Response("Denied", { status: 403 }), () => new Response("<html>Login</html>")]) {
|
||||||
|
test("SR exhausting five attempts blocks Actions and preserves the last completed timestamp", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const h = setup(async () => { calls += 1; return failure(); });
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 123;
|
||||||
|
await h.cycle("failed");
|
||||||
|
assert.equal(calls, 5);
|
||||||
|
assert.equal(h.actions.length, 0);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").state, "error");
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 123);
|
||||||
|
h.app.authSessionState.principal = "synthetic@example.invalid";
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.getSummary("A").state, "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("pagination retries only the failed SR page, with no duplicate records", async () => {
|
||||||
|
const offsets = [];
|
||||||
|
let failures = 0;
|
||||||
|
const h = setup(async (_url, options) => {
|
||||||
|
const { offset } = JSON.parse(options.body);
|
||||||
|
offsets.push(offset);
|
||||||
|
if (offset === 1 && failures++ < 2) return response({ invalid: true });
|
||||||
|
return response({ items: [{ SrNumber: `SR${offset}`, ReportedHours_c: 2, OpportunityName_Id_c: { OptyNumber: "A" } }],
|
||||||
|
offset, count: 1, hasMore: offset === 0 });
|
||||||
|
});
|
||||||
|
await h.cycle("paged");
|
||||||
|
assert.deepEqual(offsets, [0, 1, 1, 1]);
|
||||||
|
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").reportedHours, 4);
|
||||||
|
assert.equal(h.actions.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions retries each workload up to five times and stops as soon as it succeeds", async () => {
|
||||||
|
const h = setup(async () => response({ items: [], hasMore: false }));
|
||||||
|
let attempts = 0;
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return attempts < 3 ? { ok: false, error: "Synthetic" } : { ok: true, actions: [], hasNext: false };
|
||||||
|
};
|
||||||
|
await h.cycle("success");
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
attempts = 0;
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async () => { attempts += 1; throw new Error("Synthetic"); };
|
||||||
|
await h.cycle("failed-actions");
|
||||||
|
assert.equal(attempts, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an invalidated SR request does not continue retrying or launch Actions", async () => {
|
||||||
|
let reject, calls = 0;
|
||||||
|
const h = setup(() => { calls += 1; return new Promise((_resolve, fail) => { reject = fail; }); });
|
||||||
|
const pending = h.cycle("old");
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
h.app.productsContentState.currentProductsRequestId = "new";
|
||||||
|
reject(new Error("Synthetic"));
|
||||||
|
await pending;
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(h.actions.length, 0);
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
|
|||||||
const context = {
|
const context = {
|
||||||
document: {
|
document: {
|
||||||
createElement: (tag) => new Element(tag),
|
createElement: (tag) => new Element(tag),
|
||||||
|
createElementNS: (_, tag) => new Element(tag),
|
||||||
querySelectorAll: () => visibleAlerts
|
querySelectorAll: () => visibleAlerts
|
||||||
},
|
},
|
||||||
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
|
||||||
|
|||||||
68
tests/allOpportunityFilters.test.cjs
Normal file
68
tests/allOpportunityFilters.test.cjs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content");
|
||||||
|
const normal = { OptyNumber: "normal", Name: "Synthetic", ForecastGroup_c: "FORECAST", StatusCode: "OPEN",
|
||||||
|
CustomerAccount: { PartyId: "1", PartyUniqueName: "Customer" }, Owner: { PersonFirstName: "Owner" } };
|
||||||
|
const items = [normal,
|
||||||
|
{ ...normal, OptyNumber: "stage", ForecastGroup_c: null },
|
||||||
|
{ ...normal, OptyNumber: "status", StatusCode: "UNLISTED" },
|
||||||
|
{ ...normal, OptyNumber: "customer", CustomerAccount: null },
|
||||||
|
{ ...normal, OptyNumber: "owner", Owner: null }];
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
app.opportunitiesFilters.resetCustomerFilter(items);
|
||||||
|
app.opportunitiesFilters.resetOwnerFilter(items);
|
||||||
|
return { app, items, result: () => Array.from(app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("all selected includes records with missing or unlisted dimensions", () => {
|
||||||
|
const h = setup();
|
||||||
|
assert.equal(h.result().length, h.items.length);
|
||||||
|
h.app.opportunitiesFilterState.tableSearch = "normal";
|
||||||
|
assert.deepEqual(h.result(), ["normal"], "search still restricts results");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial and empty selections remain restrictive, including WON_PENDING mapping", () => {
|
||||||
|
const h = setup();
|
||||||
|
const state = h.app.opportunitiesFilterState;
|
||||||
|
state.selectedStages.delete("SQL");
|
||||||
|
assert.equal(h.result().includes("stage"), false);
|
||||||
|
state.selectedStages.add("SQL");
|
||||||
|
state.selectedStatuses.delete("CLOSED");
|
||||||
|
assert.equal(h.result().includes("status"), false);
|
||||||
|
assert.equal(h.app.opportunitiesFilters.matchesSelectedStatus("WON_PENDING"), true);
|
||||||
|
state.selectedStatuses.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("customer and owner exclusions apply only when a narrower selection is made", () => {
|
||||||
|
const h = setup();
|
||||||
|
const state = h.app.opportunitiesFilterState;
|
||||||
|
state.selectedCustomerKeys.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
state.selectedCustomerKeys.add("party:1");
|
||||||
|
assert.equal(h.result().includes("customer"), true);
|
||||||
|
state.selectedOwnerKeys.clear();
|
||||||
|
assert.deepEqual(h.result(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Owner offers one dash option for unassigned records and supports selecting it alone", () => {
|
||||||
|
const h = setup();
|
||||||
|
for (const Owner of [undefined, {}, "", " ", { PersonFirstName: " " }]) {
|
||||||
|
h.items.push({ ...h.items[0], OptyNumber: `missing-${h.items.length}`, Owner });
|
||||||
|
}
|
||||||
|
const options = Array.from(h.app.opportunitiesFormatters.getOwnerOptions());
|
||||||
|
const missing = options.filter(option => option.label === "-");
|
||||||
|
assert.equal(missing.length, 1);
|
||||||
|
assert.equal(missing[0].key, "owner:unassigned");
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set([missing[0].key]);
|
||||||
|
assert.equal(h.result().length, 6);
|
||||||
|
assert.equal(h.result().includes("normal"), false);
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set(options.map(option => option.key));
|
||||||
|
assert.equal(h.result().length, h.items.length);
|
||||||
|
h.app.opportunitiesFilterState.selectedOwnerKeys.delete(missing[0].key);
|
||||||
|
assert.equal(h.result().includes("owner"), false);
|
||||||
|
assert.equal(h.result().length, 4);
|
||||||
|
});
|
||||||
113
tests/badgeIcons.test.cjs
Normal file
113
tests/badgeIcons.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { loadApplication, composeScript } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
class Element {
|
||||||
|
constructor(tag) {
|
||||||
|
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = "";
|
||||||
|
this.classList = { toggle() {} };
|
||||||
|
}
|
||||||
|
set textContent(value) { this.text = String(value); this.children = []; }
|
||||||
|
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||||
|
append(...children) { this.children.push(...children); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, callback) { this.listeners[key] = callback; }
|
||||||
|
}
|
||||||
|
function setup() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), getElementById: () => null,
|
||||||
|
createElementNS: (namespace, tag) => Object.assign(new Element(tag), { namespace }) } });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
app.activityProgress.update = () => {};
|
||||||
|
app.authSessionState.principal = "me@example.invalid";
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
function checkIcon(badge, kind, value) {
|
||||||
|
assert.equal(badge.textContent, value);
|
||||||
|
const images = badge.children.filter(child => child.tag === "svg");
|
||||||
|
assert.equal(images.length, 1);
|
||||||
|
const icon = images[0];
|
||||||
|
assert.equal(icon.namespace, "http://www.w3.org/2000/svg");
|
||||||
|
assert.equal(icon.attributes.viewBox, "0 0 24 24");
|
||||||
|
assert.equal(icon.attributes.fill, "none");
|
||||||
|
assert.equal(icon.attributes.stroke, "currentColor");
|
||||||
|
assert.equal(icon.attributes["stroke-width"], "2.25");
|
||||||
|
assert.equal(icon.attributes["stroke-linecap"], "round");
|
||||||
|
assert.equal(icon.attributes["stroke-linejoin"], "round");
|
||||||
|
assert.equal(icon.attributes["data-badge-icon"], kind);
|
||||||
|
assert.equal(icon.attributes["aria-hidden"], "true");
|
||||||
|
assert.equal(icon.attributes.focusable, "false");
|
||||||
|
assert.equal(icon.attributes.width, "16");
|
||||||
|
assert.equal(icon.attributes.height, "16");
|
||||||
|
assert.ok(icon.children.length >= 3);
|
||||||
|
assert.ok(icon.children.every(child => ["path", "rect", "polyline"].includes(child.tag)));
|
||||||
|
assert.ok(icon.children.every(child => child.namespace === icon.namespace));
|
||||||
|
assert.equal(badge.children.some(child => child.tag === "img"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
|
||||||
|
const app = setup();
|
||||||
|
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
|
||||||
|
const count = app.productsDetails.createProductsCountButton("TEST", products);
|
||||||
|
checkIcon(count, "products", "3");
|
||||||
|
assert.equal(count.title, "3 products");
|
||||||
|
assert.equal(typeof count.listeners.click, "function");
|
||||||
|
const ramp = app.productsDetails.createProductsRampBadge(products);
|
||||||
|
checkIcon(ramp, "ramp", "1,2,3");
|
||||||
|
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
||||||
|
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
||||||
|
checkIcon(missingRamp, "ramp", "N/D");
|
||||||
|
assert.equal(missingRamp.attributes["data-missing"], "true");
|
||||||
|
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||||
|
{ SrNumber: "SR-TEST", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST" } }
|
||||||
|
];
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", app.opportunitiesQueryState.periodRequestVersion);
|
||||||
|
const sr = app.opportunityCrmServiceRequests.createBadge("TEST");
|
||||||
|
checkIcon(sr, "sr", "4h");
|
||||||
|
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(typeof sr.listeners.click, "function");
|
||||||
|
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h");
|
||||||
|
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("TEST", products);
|
||||||
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, hasNext: false, actions: [
|
||||||
|
{ id: 1, owner: "me@example.invalid", name: "My action" },
|
||||||
|
{ id: 2, owner: "other@example.invalid", name: "Other action" }
|
||||||
|
] });
|
||||||
|
await app.opportunityWorkloadActions.loadProducts(products);
|
||||||
|
const actions = app.opportunityWorkloadActions.createBadge("TEST");
|
||||||
|
checkIcon(actions, "actions", "2");
|
||||||
|
assert.equal(actions.attributes["data-state"], "matched");
|
||||||
|
assert.match(actions.title, /My action/);
|
||||||
|
assert.match(actions.title, /Other action/);
|
||||||
|
app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
assert.equal(app.opportunityWorkloadActions.createBadge("TEST"), null);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vector shapes and their license ship in both distributions without raster images or requests", () => {
|
||||||
|
const source = composeScript("content");
|
||||||
|
const module = fs.readFileSync(path.join(root, "src/features/opportunities/badge-content.js"), "utf8");
|
||||||
|
assert.ok(source.includes(module));
|
||||||
|
assert.doesNotMatch(source, /opportunityBadgeImages|data:image\/png;base64/);
|
||||||
|
for (const target of ["chromium", "firefox"]) {
|
||||||
|
const bundle = fs.readFileSync(path.join(root, "dist", target, "content.js"), "utf8");
|
||||||
|
assert.ok(bundle.includes(module));
|
||||||
|
assert.ok(bundle.includes("Copyright (c) 2026 Lucide Icons and Contributors"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("icon styles preserve uniform dimensions and provide light, dark and active contrast", () => {
|
||||||
|
const css = fs.readFileSync(path.join(root, "src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const icon = css.match(/\.opportunities-extension-badge-icon \{([^}]+)\}/)[1];
|
||||||
|
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "color: inherit;", "stroke: currentColor;", "stroke-width: 2.25;"]) {
|
||||||
|
assert.ok(icon.includes(rule), rule);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(icon, /filter:|mix-blend-mode:/);
|
||||||
|
assert.match(css, /gap: 4px;\s*height: 20px;/);
|
||||||
|
assert.match(css, /cell-actions > \.opportunities-extension-crm-sr-badge \{\s*display: inline-flex;\s*align-items: center;\s*justify-content: flex-start;/);
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ test("distribution contracts: manifests, execution boundaries and deterministic
|
|||||||
manifest_version: 3, name: "Opportunities Extension",
|
manifest_version: 3, name: "Opportunities Extension",
|
||||||
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
|
||||||
version: "1.0.2", permissions: ["cookies", "storage"],
|
version: "1.0.2", permissions: ["cookies", "storage"],
|
||||||
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*"],
|
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
|
||||||
content_scripts: [{
|
content_scripts: [{
|
||||||
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],
|
||||||
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
js: ["content.js"], all_frames: true, run_at: "document_idle"
|
||||||
|
|||||||
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal file
73
tests/consumerTokenAuthorizationRecovery.test.cjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const tokens = [
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } },
|
||||||
|
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-actions" } }
|
||||||
|
];
|
||||||
|
const request = { type: "opportunitiesExtension.requestConsumerTokenService" };
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
for (const status of [401, 403]) {
|
||||||
|
test(`${family}: HTTP ${status} opens an inactive SPA session and closes it after valid JSON`, async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async (_url, options) => {
|
||||||
|
attempts += 1;
|
||||||
|
assert.equal(options.credentials, "include");
|
||||||
|
if (attempts === 1) return response({ error: "Session unavailable" }, status);
|
||||||
|
assert.equal(h.tabs.length, 1, "session tab must open before retrying");
|
||||||
|
return response(tokens);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.equal(h.tabs[0].active, false);
|
||||||
|
assert.equal(h.tabs[0].windowId, 3);
|
||||||
|
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
|
||||||
|
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html");
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.equal(result.debugRequests[0].httpStatus, status);
|
||||||
|
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
|
||||||
|
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "closed-after-valid-json");
|
||||||
|
assert.equal(result.authorizations.workloadActions.value, "synthetic-actions");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`${family}: persistent 403 stops after five attempts and removes the auxiliary tab`, async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ family, fetch: async () => { attempts += 1; return response({}, 403); } });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(attempts, 5);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.match(result.error, /403 after 5 attempts/);
|
||||||
|
assert.equal(result.debugRequests.at(-1).metadata.retryLimitReached, "true");
|
||||||
|
assert.equal(result.debugRequests.at(-1).metadata.sessionBootstrapState, "closed-after-retry-finished");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mixed 403/HTML recovery shares one tab and the existing retry budget", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const h = loadBackground({ fetch: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) return response({}, 403);
|
||||||
|
if (attempts === 2) return new Response("<html>Login</html>");
|
||||||
|
return response(tokens);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
assert.equal(h.tabs.length, 1);
|
||||||
|
assert.deepEqual(h.removedTabs, [91]);
|
||||||
|
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrelated HTTP failures do not launch the SPA session page", async () => {
|
||||||
|
const h = loadBackground({ fetch: async () => response({}, 500) });
|
||||||
|
const result = await h.dispatch(request);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(h.tabs.length, 0);
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@ const { test } = require("node:test");
|
|||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
const response = data => new Response(JSON.stringify(data), { status: 200 });
|
||||||
function setup(fetch) {
|
function setup(fetch) {
|
||||||
const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
const app = loadApplication("content", { fetch, document, Intl, Date });
|
const app = loadApplication("content", { fetch, document, Intl, Date });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.debugContentView.renderDebugPanel = () => {};
|
app.debugContentView.renderDebugPanel = () => {};
|
||||||
@@ -14,6 +15,19 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
|||||||
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
|
||||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||||
|
|
||||||
|
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
||||||
|
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
|
||||||
|
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").title, "Whether a Service Request has been created for the opportunities.");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").attributes["data-state"], "missing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("CRM fetch is called without the environment object as its receiver", async () => {
|
test("CRM fetch is called without the environment object as its receiver", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const app = setup(async function (url, options) {
|
const app = setup(async function (url, options) {
|
||||||
@@ -28,7 +42,7 @@ test("CRM fetch is called without the environment object as its receiver", async
|
|||||||
assert.equal(items.length, 2);
|
assert.equal(items.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CRM query preserves payload, paginates before opportunities, uses tokenrelay Bearer and logs each page", async () => {
|
test("CRM query preserves payload and pagination after opportunities, uses tokenrelay Bearer and logs each page", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = setup(async (url, options) => {
|
const app = setup(async (url, options) => {
|
||||||
const payload = JSON.parse(options.body);
|
const payload = JSON.parse(options.body);
|
||||||
@@ -40,11 +54,12 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
|
|||||||
app.productsContentJobs.renderProductsProgress = () => {};
|
app.productsContentJobs.renderProductsProgress = () => {};
|
||||||
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
|
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
|
||||||
app.fusionOpportunities.requestOpportunitiesPage = async () => {
|
app.fusionOpportunities.requestOpportunitiesPage = async () => {
|
||||||
assert.equal(calls.length, 2, "all CRM pages must finish first");
|
assert.equal(calls.length, 0, "CRM must not run before product loading");
|
||||||
return [{ OptyNumber: "TEST-1" }];
|
return [{ OptyNumber: "TEST-1" }];
|
||||||
};
|
};
|
||||||
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
|
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
|
||||||
assert.equal(states.at(-1).status, "ready");
|
assert.equal(states.at(-1).status, "ready");
|
||||||
|
await app.opportunityCrmServiceRequests.load("synthetic-token", 1);
|
||||||
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
|
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
|
||||||
const { payload, options, url } = calls[0];
|
const { payload, options, url } = calls[0];
|
||||||
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
|
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
|
||||||
@@ -79,11 +94,11 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
|
|||||||
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
|
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
|
||||||
}
|
}
|
||||||
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
|
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
|
||||||
assert.equal(found.textContent, "\u2705 SR");
|
assert.equal(found.textContent, "0h");
|
||||||
assert.equal(found.attributes["data-state"], "matched");
|
assert.equal(found.attributes["data-state"], "matched");
|
||||||
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
|
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
|
||||||
assert.ok(found.attributes["aria-label"].includes(detail));
|
assert.ok(found.attributes["aria-label"].includes(detail));
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
|
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
|
||||||
app.opportunityCrmServiceRequests.reset();
|
app.opportunityCrmServiceRequests.reset();
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
|
||||||
@@ -107,7 +122,8 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a
|
|||||||
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
|
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
|
||||||
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
|
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
|
||||||
assert.equal(loaded, true);
|
assert.equal(loaded, true);
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?");
|
await app.opportunityCrmServiceRequests.load("token", 1);
|
||||||
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "?");
|
||||||
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
|
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -122,8 +138,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
|
|||||||
await app.opportunityCrmServiceRequests.load("token", 2);
|
await app.opportunityCrmServiceRequests.load("token", 2);
|
||||||
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
|
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
|
||||||
await old;
|
await old;
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
|
||||||
assert.equal(app.debugState.requestLog.length, 1);
|
assert.equal(app.debugState.requestLog.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +160,8 @@ test("Reference changes share pending pagination and reuse completed CRM results
|
|||||||
app.opportunitiesQueryState.periodRequestVersion = 3;
|
app.opportunitiesQueryState.periodRequestVersion = 3;
|
||||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||||
assert.equal(calls, 2);
|
assert.equal(calls, 2);
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR");
|
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
|
||||||
app.opportunityCrmServiceRequests.reset();
|
app.opportunityCrmServiceRequests.reset();
|
||||||
await app.opportunityCrmServiceRequests.load("token", 3);
|
await app.opportunityCrmServiceRequests.load("token", 3);
|
||||||
assert.equal(calls, 3);
|
assert.equal(calls, 3);
|
||||||
|
|||||||
41
tests/crmSrDetails.test.cjs
Normal file
41
tests/crmSrDetails.test.cjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const type = "opportunitiesExtension.requestInternalServiceRequest";
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const children = [{ SrId: 123, SrNumber: "SR123", Title: "Synthetic" }];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: children }] });
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type, srId: "987", token: "secret-test" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(result.items, children);
|
||||||
|
const url = new URL(calls[0].url);
|
||||||
|
assert.equal(url.hostname, "comcipapic-oalprod.integration.ocp.oraclecloud.com");
|
||||||
|
assert.equal(url.searchParams.get("q"), "SrId=987");
|
||||||
|
assert.equal(url.searchParams.get("onlyData"), "true");
|
||||||
|
assert.ok(url.searchParams.get("fields").includes("ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c"));
|
||||||
|
assert.equal(calls[0].options.method, "GET");
|
||||||
|
assert.equal(calls[0].options.headers.Authorization, "Session");
|
||||||
|
assert.equal(calls[0].options.credentials, "include");
|
||||||
|
assert.equal(result.debugRequests[0].requestHeaders.Authorization, "Session");
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret-test"));
|
||||||
|
assert.equal(result.debugRequests[0].name, "requestInternalServiceRequest");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("SR details distinguish empty collections from errors and validate inputs", async () => {
|
||||||
|
for (const body of [{ items: [] }, { items: [{}] }, { items: [{ ServiceRequest_SRToInternalSR_Tgt: [] }] }]) {
|
||||||
|
const h = loadBackground({ fetch: async () => response(body) });
|
||||||
|
assert.deepEqual((await h.dispatch({ type, srId: "1" })).items, []);
|
||||||
|
}
|
||||||
|
for (const fetch of [async () => response({}, 403), async () => response({}), async () => response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: {} }] })]) {
|
||||||
|
const h = loadBackground({ fetch });
|
||||||
|
assert.equal((await h.dispatch({ type, srId: "1", token: "test" })).ok, false);
|
||||||
|
}
|
||||||
|
const h = loadBackground({ fetch: () => assert.fail("Invalid input must not fetch") });
|
||||||
|
assert.equal((await h.dispatch({ type, srId: "1 OR 1=1", token: "test" })).ok, false);
|
||||||
|
assert.equal((await h.dispatch({ type })).ok, false);
|
||||||
|
});
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const { loadApplication } = require("./support/application.cjs");
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
|
test("internal SR uses Session without TokenRelay or retries on 401", async () => {
|
||||||
|
const h = setup();
|
||||||
|
let renewals = 0;
|
||||||
|
const sent = [];
|
||||||
|
h.app.authSessionState.accessToken = "stale-token";
|
||||||
|
h.app.authTokenRelay.requestTokenRelay = async () => `fresh-${++renewals}`;
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = async message => {
|
||||||
|
sent.push(message);
|
||||||
|
return { ok: false, httpStatus: 401, error: "Unauthorized" };
|
||||||
|
};
|
||||||
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
|
||||||
|
h.modal.append(badge);
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
await flush();
|
||||||
|
assert.equal(renewals, 0);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0].token, undefined);
|
||||||
|
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
|
||||||
|
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Unauthorized"));
|
||||||
|
});
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const document = { activeElement: null, listeners: new Map() };
|
const document = { activeElement: null, listeners: new Map() };
|
||||||
@@ -104,3 +129,42 @@ test("SR popover pins on click, closes on Escape/outside/scroll/reset and remove
|
|||||||
assert.equal(panel(), undefined);
|
assert.equal(panel(), undefined);
|
||||||
assert.equal(document.listeners.size, 0);
|
assert.equal(document.listeners.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SR tooltip loads nested details with copy/link and empty text, discarding stale responses", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const calls = [];
|
||||||
|
h.app.authSessionState.accessToken = "crm-token";
|
||||||
|
h.app.authTokenRelay.requestTokenRelay = async () => "fresh-crm-token";
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
|
||||||
|
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
|
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
|
||||||
|
h.modal.append(badge);
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
await flush();
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].message.token, undefined);
|
||||||
|
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
|
||||||
|
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Loading SR details..."));
|
||||||
|
calls[0].resolve({ ok: true, items: [{ SrId: 99, SrNumber: "SR &child", Title: "<child>", ServiceNew_c: "Queue" }] });
|
||||||
|
calls[1].resolve({ ok: true, items: [] });
|
||||||
|
await flush();
|
||||||
|
const nodes = h.walk(h.panel());
|
||||||
|
const link = nodes.find(node => node.tag === "a" && node.textContent === "SR &child");
|
||||||
|
assert.equal(link.parent.children[0].textContent, "Internal SR Number:");
|
||||||
|
assert.ok(link.href.endsWith("srNumber=SR%20%26child&srId=99"));
|
||||||
|
assert.equal(link.parent.children[2].title, "Copy SR number");
|
||||||
|
assert.ok(nodes.some(node => node.textContent.includes("Service Queue: Queue")));
|
||||||
|
assert.ok(nodes.some(node => node.textContent === "No further details."));
|
||||||
|
h.app.crmSrPopover?.close();
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
badge.listeners.pointerenter();
|
||||||
|
const oldPanel = h.panel();
|
||||||
|
await flush();
|
||||||
|
h.app.opportunityCrmServiceRequests.reset();
|
||||||
|
calls[2].resolve({ ok: true, items: [{ SrNumber: "STALE" }] });
|
||||||
|
calls[3].resolve({ ok: false, error: "Forbidden" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.panel(), undefined);
|
||||||
|
assert.ok(!h.walk(oldPanel).some(node => node.textContent === "STALE"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -73,10 +73,12 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
|
|||||||
assert.equal(same, app.currentUser.ensure());
|
assert.equal(same, app.currentUser.ensure());
|
||||||
finish({ ok: true, user: { roles } });
|
finish({ ok: true, user: { roles } });
|
||||||
await first;
|
await first;
|
||||||
assert.deepEqual(calls, allowed ? [type, "tokenrelay", "crm", "opportunities"] : [type, "tokenrelay", "opportunities"]);
|
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
|
||||||
|
app.productsContentState.currentProductsRequestId = "products-cycle";
|
||||||
|
app.productActivities.begin("products-cycle", "synthetic");
|
||||||
const products = { items: [{ RevnId: "123" }] };
|
const products = { items: [{ RevnId: "123" }] };
|
||||||
app.productsContentState.opptyProductsByNumber.set("OP", products);
|
app.productsContentState.opptyProductsByNumber.set("OP", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.productActivities.complete("products-cycle");
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
|
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
48
tests/dashboardTotals.test.cjs
Normal file
48
tests/dashboardTotals.test.cjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor() { this.children = []; this.attributes = {}; }
|
||||||
|
set textContent(value) { this.text = value; this.children = []; }
|
||||||
|
get textContent() { return this.text; }
|
||||||
|
append(...nodes) { this.children.push(...nodes); }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Total Opportunities displays filtered/overall and updates with filters and Reference data", () => {
|
||||||
|
const dashboard = new Element(), table = new Element();
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: {
|
||||||
|
createElement: () => new Element(),
|
||||||
|
getElementById: id => id === "opportunities-extension-stage-dashboard" ? dashboard
|
||||||
|
: id === "opportunities-extension-table" ? table : null
|
||||||
|
},
|
||||||
|
performance: { now: () => 0 }, requestAnimationFrame: tick => tick(480)
|
||||||
|
});
|
||||||
|
// Use real table/dashboard coordination without constructing unrelated row controls.
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.status = "loading";
|
||||||
|
const items = Array.from({ length: 26 }, () => ({ ForecastGroup_c: "FORECAST" }));
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items;
|
||||||
|
let filtered = items.slice(0, 11);
|
||||||
|
app.opportunitiesFilters.getSortedOpportunities = () => filtered;
|
||||||
|
const value = () => dashboard.children[0].children[1];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "11/26");
|
||||||
|
assert.equal(value().attributes["aria-label"], "11 filtered opportunities out of 26 total");
|
||||||
|
filtered = [];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "0/26");
|
||||||
|
filtered = items;
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "26/26");
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = items.slice(0, 3);
|
||||||
|
filtered = items.slice(0, 2);
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "2/3");
|
||||||
|
app.opportunitiesTableState.opportunitiesTableState.items = [];
|
||||||
|
filtered = [];
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
assert.equal(value().textContent, "0/0");
|
||||||
|
});
|
||||||
@@ -20,6 +20,19 @@ function fixture() {
|
|||||||
return { ...dom, app, add, nodes, tab, ids, select };
|
return { ...dom, app, add, nodes, tab, ids, select };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("request summaries show duration after status, including zero, failure and pending", () => {
|
||||||
|
const h = fixture();
|
||||||
|
h.add("duration", "requestOpportunities", 1);
|
||||||
|
const entry = h.app.debugState.requestLog.at(-1);
|
||||||
|
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
|
||||||
|
Object.assign(entry, { durationMs, status });
|
||||||
|
h.app.debugContentView.renderDebugPanel();
|
||||||
|
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
|
||||||
|
assert.equal(meta.children.at(-1).textContent, expected);
|
||||||
|
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
|
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
|
||||||
const h = fixture();
|
const h = fixture();
|
||||||
const background = loadApplication("background");
|
const background = loadApplication("background");
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ test("ten columns retain their links and put product/customer controls below the
|
|||||||
assert.ok(name.children[0].href.endsWith("TEST-1"));
|
assert.ok(name.children[0].href.endsWith("TEST-1"));
|
||||||
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
|
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
|
||||||
assert.equal(name.children[1].children[0].textContent, "1");
|
assert.equal(name.children[1].children[0].textContent, "1");
|
||||||
|
assert.equal(name.children[1].children[0].title, "1 product");
|
||||||
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");
|
||||||
@@ -69,10 +70,11 @@ test("ten columns retain their links and put product/customer controls below the
|
|||||||
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
|
test("all four filter fields share available width dynamically with responsive wrapping", () => {
|
||||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
|
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
|
||||||
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
|
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||||
assert.match(css, /@media \(max-width: 1150px\)/);
|
assert.match(css, /@media \(max-width: 1150px\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +83,8 @@ test("ramp months preserve zero and flag missing values beside the product count
|
|||||||
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
|
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
|
||||||
assert.equal(badge(), undefined);
|
assert.equal(badge(), undefined);
|
||||||
for (const [values, expected] of [
|
for (const [values, expected] of [
|
||||||
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
|
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
|
||||||
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"]
|
[[0], "0"], [[0, "3", null, " 6 ", 3], "0,3,N/D,6,3"]
|
||||||
]) {
|
]) {
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
||||||
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
|
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
|
||||||
@@ -92,10 +94,13 @@ test("ramp months preserve zero and flag missing values beside the product count
|
|||||||
else {
|
else {
|
||||||
assert.equal(badge().textContent, expected);
|
assert.equal(badge().textContent, expected);
|
||||||
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
|
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
|
||||||
assert.match(badge().title, /^Ramp Months:/);
|
assert.equal(badge().title, `Ramp Months: ${expected.split(",").join(", ")}`);
|
||||||
|
assert.equal(badge().attributes["aria-label"], badge().title);
|
||||||
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
||||||
}
|
}
|
||||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count").textContent, String(values.length));
|
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
|
||||||
|
assert.equal(count.textContent, String(values.length));
|
||||||
|
assert.equal(count.title, `${values.length} product${values.length === 1 ? "" : "s"}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +122,33 @@ test("products remain expandable with AI summary and a ten-column detail row aft
|
|||||||
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
|
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("product labels support multiple digits without changing their tooltip", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: Array.from({ length: 12 }, () => ({})) });
|
||||||
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
|
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
|
||||||
|
assert.equal(count.textContent, "12");
|
||||||
|
assert.equal(count.title, "12 products");
|
||||||
|
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
|
||||||
|
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
|
||||||
|
assert.ok(shared, "P, R, SR and Act share one scoped badge rule");
|
||||||
|
for (const declaration of ["display: inline-flex;", "align-items: center;", "justify-content: flex-start;",
|
||||||
|
"box-sizing: border-box;", "height: 20px;", "min-height: 20px;", "padding: 0 6px;", "margin: 0;",
|
||||||
|
"border-radius: 8px;", "font-family: inherit;", "font-size: 10px;", "font-weight: 600;", "line-height: 16px;"]) {
|
||||||
|
assert.ok(shared[1].includes(declaration), declaration);
|
||||||
|
}
|
||||||
|
const plus = css.match(/\.opportunities-extension-add-action\s*\{([^}]+)\}/)[1];
|
||||||
|
assert.match(plus, /height: 16px;/);
|
||||||
|
assert.match(plus, /align-items: center;/);
|
||||||
|
assert.match(plus, /font: inherit;/);
|
||||||
|
const actions = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/workload-actions.js"), "utf8");
|
||||||
|
assert.match(actions, /badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"/);
|
||||||
|
});
|
||||||
|
|
||||||
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
|
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
|
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
|
||||||
@@ -124,11 +156,11 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
|
|||||||
}];
|
}];
|
||||||
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
|
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
|
||||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "\u2705 SR");
|
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
|
||||||
h.app.opportunitiesTable.renderOpportunitiesTable();
|
h.app.opportunitiesTable.renderOpportunitiesTable();
|
||||||
const actions = h.cell("name").children[0].children[1];
|
const actions = h.cell("name").children[0].children[1];
|
||||||
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR", "Act ?"]);
|
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
|
||||||
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
||||||
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
|
||||||
});
|
});
|
||||||
@@ -210,7 +242,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
|||||||
assert.match(css, /flex: 0 0 24px/);
|
assert.match(css, /flex: 0 0 24px/);
|
||||||
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
|
||||||
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
||||||
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "");
|
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
|
||||||
|
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "");
|
||||||
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
|
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
|
||||||
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
|
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
|
||||||
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
|
||||||
|
|||||||
187
tests/productActivities.test.cjs
Normal file
187
tests/productActivities.test.cjs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
function deferred() {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const first of ["SR", "Actions"]) {
|
||||||
|
test(`Last update waits for every background call when ${first} finishes first`, async () => {
|
||||||
|
const h = setup();
|
||||||
|
const sr = deferred(), actions = deferred();
|
||||||
|
let renders = 0;
|
||||||
|
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
|
||||||
|
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
h.progress(id, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
(first === "SR" ? sr : actions).resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
(first === "SR" ? actions : sr).resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
assert.equal(renders, 1, "publish the completed cycle once");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const old = deferred(), current = deferred();
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => old.promise;
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => current.promise;
|
||||||
|
const second = h.start();
|
||||||
|
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
|
||||||
|
old.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
current.reject(new Error("Synthetic failure"));
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
assert.equal(h.calls.includes("empty products"), false, "SR failure blocks Actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const restore = deferred();
|
||||||
|
h.app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
h.app.opportunitiesQueryState.periodRequestVersion += 1;
|
||||||
|
restore.resolve();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
|
||||||
|
const next = h.start();
|
||||||
|
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Last update completes for SALES_REP without activity requests", async () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunityCrmServiceRequests.load = () => undefined;
|
||||||
|
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
|
||||||
|
const id = h.start();
|
||||||
|
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
let listener;
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
document: { getElementById: () => null }, window: { requestAnimationFrame() {} },
|
||||||
|
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||||
|
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
|
||||||
|
app.opportunityCrmServiceRequests.load = async (token, version, options) => { if (!options?.cacheOnly) calls.push(`SR ${token}`); };
|
||||||
|
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
|
||||||
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
|
function start(token = "token") {
|
||||||
|
app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], token);
|
||||||
|
return app.productsContentState.currentProductsRequestId;
|
||||||
|
}
|
||||||
|
function progress(requestId, data) {
|
||||||
|
listener({ type: "opportunitiesExtension.opptyProductsProgress", requestId, ...data });
|
||||||
|
}
|
||||||
|
return { app, calls, start, progress };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("follow-up calls wait for completion, deduplicate terminal messages and refresh on every new cycle", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false });
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
h.progress(first, { state: "complete" });
|
||||||
|
h.progress(first, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, ["SR token", "123"]);
|
||||||
|
const second = h.start("new-token");
|
||||||
|
h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] });
|
||||||
|
h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] });
|
||||||
|
assert.equal(h.calls.filter(value => value === "123").length, 1);
|
||||||
|
h.progress(second, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.filter(value => value === "123").length, 2);
|
||||||
|
assert.ok(h.calls.includes("SR new-token"));
|
||||||
|
assert.equal(h.calls.includes("old"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial failures use successful products only; closed, failed and empty cycles have no follow-up", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const first = h.start();
|
||||||
|
h.progress(first, { optyNumber: "A", items: [{ RevnId: "123" }] });
|
||||||
|
h.progress(first, { optyNumber: "B", items: [{ RevnId: "failed" }], error: "Synthetic failure" });
|
||||||
|
h.progress(first, { state: "complete-with-errors" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.includes("failed"), false);
|
||||||
|
assert.equal(h.calls.filter(value => value === "SR token").length, 1);
|
||||||
|
const second = h.start();
|
||||||
|
h.progress(second, { optyNumber: "A", error: "Synthetic failure" });
|
||||||
|
h.progress(second, { state: "complete-with-errors" });
|
||||||
|
const third = h.start();
|
||||||
|
h.progress(third, { state: "error" });
|
||||||
|
const fourth = h.start();
|
||||||
|
h.app.productsContentState.currentProductsRequestId = "";
|
||||||
|
h.progress(fourth, { state: "complete", optyNumber: "A", items: [{ RevnId: "closed" }] });
|
||||||
|
h.app.productsContentJobs.requestOpptyProductsInBackground([], "empty");
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.filter(value => value.startsWith("SR ")).length, 1);
|
||||||
|
assert.equal(h.calls.includes("closed"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cached products restore activities without network; mixed cycles fetch only fresh products", async () => {
|
||||||
|
const h = setup();
|
||||||
|
const cached = h.start();
|
||||||
|
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
|
h.progress(cached, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, []);
|
||||||
|
const mixed = h.start();
|
||||||
|
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
|
||||||
|
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
|
||||||
|
h.progress(mixed, { state: "complete" });
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls, ["SR token", "fresh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reopening an existing modal does not reload products or reset/query activities", async () => {
|
||||||
|
const modal = { hidden: true, querySelector: () => ({ focus() {} }) };
|
||||||
|
const app = loadApplication("content", {
|
||||||
|
window: {}, document: {
|
||||||
|
getElementById: id => id === "opportunities-extension-modal" ? modal : null,
|
||||||
|
documentElement: { classList: { add() {} } }, addEventListener() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.contentStyles.ensureExtensionStyles = () => {};
|
||||||
|
app.currentUser.ensure = async () => true;
|
||||||
|
app.authContentSession.requestTokenRelayOnce = async () => "synthetic";
|
||||||
|
app.opportunitiesTable.renderOpportunitiesTable = () => {};
|
||||||
|
const calls = [];
|
||||||
|
app.productsContentJobs.requestOpptyProductsInBackground = () => calls.push("products");
|
||||||
|
app.opportunityCrmServiceRequests.load = () => calls.push("SR");
|
||||||
|
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
|
||||||
|
app.opportunityWorkloadActions.loadProducts = () => calls.push("Actions");
|
||||||
|
app.opportunityWorkloadActions.reset = () => calls.push("reset Actions");
|
||||||
|
app.contentModal.openOpportunitiesModal();
|
||||||
|
await flush();
|
||||||
|
assert.equal(modal.hidden, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
106
tests/refreshAll.test.cjs
Normal file
106
tests/refreshAll.test.cjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: invalidation detaches old product flights and prevents stale cache restoration`, async () => {
|
||||||
|
const pending = [];
|
||||||
|
const storage = { preferences: { keep: true } };
|
||||||
|
const app = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
|
||||||
|
const message = { type: "opportunitiesExtension.requestOpptyProducts", accessToken: "synthetic", optyNumbers: ["A"], cacheMaxAgeMs: 86400000 };
|
||||||
|
const old = app.dispatch({ ...message, requestId: "old" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 1);
|
||||||
|
const clear = await app.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" });
|
||||||
|
assert.equal(clear.ok, true);
|
||||||
|
const fresh = app.dispatch({ ...message, requestId: "fresh" });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
pending[1](response({ items: [{ RevnId: "new" }], count: 1, hasMore: false }));
|
||||||
|
await fresh;
|
||||||
|
pending[0](response({ items: [{ RevnId: "old" }], count: 1, hasMore: false }));
|
||||||
|
await old;
|
||||||
|
await app.dispatch({ ...message, requestId: "cached" });
|
||||||
|
assert.equal(pending.length, 2);
|
||||||
|
assert.equal(storage["opportunitiesExtension.opptyProductsCache.v1"].A.items[0].RevnId, "new");
|
||||||
|
assert.deepEqual(storage.preferences, { keep: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("activity invalidation preserves unrelated storage and rejects late entries from old product timestamps", async () => {
|
||||||
|
const values = { preferences: "keep" };
|
||||||
|
const app = loadApplication("content", { browser: { storage: { local: {
|
||||||
|
get: async key => key === null ? { ...values } : { [key]: values[key] },
|
||||||
|
set: async data => Object.assign(values, data),
|
||||||
|
remove: async keys => keys.forEach(key => delete values[key])
|
||||||
|
} } } });
|
||||||
|
app.authSessionState.principal = "user@example.invalid";
|
||||||
|
const stamp = Date.now() - 1000;
|
||||||
|
await app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
const oldKey = Object.keys(values).find(key => key.includes("activities.v1:"));
|
||||||
|
const oldEntry = values[oldKey];
|
||||||
|
await app.activityCache.invalidate();
|
||||||
|
assert.equal(values[oldKey], undefined);
|
||||||
|
assert.equal(values.preferences, "keep");
|
||||||
|
values[oldKey] = oldEntry;
|
||||||
|
assert.equal(await app.activityCache.read("sr", "all", stamp), null);
|
||||||
|
delete values[oldKey];
|
||||||
|
await app.activityCache.write("sr", "all", stamp, []);
|
||||||
|
assert.equal(values[oldKey], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function content() {
|
||||||
|
const app = loadApplication("content", { document: { createElement: () => ({ children: [], append(node) { this.children.push(node); }, listeners: {}, attributes: {}, addEventListener(key, fn) { this.listeners[key] = fn; }, setAttribute(key, value) { this.attributes[key] = value; } }) } });
|
||||||
|
app.contentIcons.createRefreshIcon = () => ({ tag: "svg" });
|
||||||
|
for (const [object, method] of [
|
||||||
|
["opportunityCrmServiceRequests", "reset"], ["opportunityWorkloadActions", "reset"],
|
||||||
|
["consumptionPlansView", "closeAccountPlansModal"], ["installBaseView", "closeInstallBaseModal"],
|
||||||
|
["serviceRequestsView", "closeServiceRequestsModal"], ["productsContentJobs", "renderProductsProgress"],
|
||||||
|
["opportunitiesTable", "setOpportunitiesTableState"]
|
||||||
|
]) app[object][method] = () => {};
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
test("Refresh all deduplicates clicks, clears memory before reload and preserves identity/preferences", async () => {
|
||||||
|
const app = content();
|
||||||
|
const calls = [];
|
||||||
|
let finish;
|
||||||
|
app.activityCache.invalidate = () => { calls.push("activities"); return new Promise(resolve => { finish = resolve; }); };
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = async message => { calls.push(message.type); return { ok: true }; };
|
||||||
|
app.opportunitiesController.requestOpportunitiesForPeriod = async period => calls.push(period);
|
||||||
|
app.authSessionState.accessToken = "synthetic";
|
||||||
|
app.preferencesState.opptyProductsCacheHours = 48;
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", {});
|
||||||
|
app.productsContentState.productsLastCachedAt = 100;
|
||||||
|
app.consumptionTrendState.consumptionTrendCache.set("A", {});
|
||||||
|
const first = app.refreshAll.refresh("Next Quarter");
|
||||||
|
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
|
||||||
|
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
|
||||||
|
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
|
||||||
|
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
|
||||||
|
assert.deepEqual(calls, ["activities"]);
|
||||||
|
finish();
|
||||||
|
await first;
|
||||||
|
assert.deepEqual(calls, ["activities", "opportunitiesExtension.invalidateProductsCache", "Next Quarter"]);
|
||||||
|
assert.equal(app.authSessionState.accessToken, "synthetic");
|
||||||
|
assert.equal(app.preferencesState.opptyProductsCacheHours, 48);
|
||||||
|
});
|
||||||
|
test("Refresh all reports failures, does not reload on failed invalidation, and re-enables the button", async () => {
|
||||||
|
const app = content();
|
||||||
|
app.activityCache.invalidate = async () => { throw new Error("Synthetic storage failure"); };
|
||||||
|
let notified = "";
|
||||||
|
app.opportunitiesLinks.showCopyConfirmation = message => { notified = message; };
|
||||||
|
app.opportunitiesController.requestOpportunitiesForPeriod = () => assert.fail("must not reload");
|
||||||
|
const button = app.refreshAll.createButton(() => "Current Quarter");
|
||||||
|
const request = button.listeners.click();
|
||||||
|
assert.equal(button.disabled, true);
|
||||||
|
await request;
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(button.title, "Refresh all");
|
||||||
|
assert.equal(button.attributes["aria-label"], "Refresh all");
|
||||||
|
assert.equal(button.children.length, 1);
|
||||||
|
assert.equal(button.children[0].tag, "svg");
|
||||||
|
assert.equal(button.attributes["aria-busy"], "false");
|
||||||
|
assert.equal(notified, "Synthetic storage failure");
|
||||||
|
});
|
||||||
415
tests/workloadActionForm.test.cjs
Normal file
415
tests/workloadActionForm.test.cjs
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadApplication } = require("./support/application.cjs");
|
||||||
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
const lookupData = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
|
||||||
|
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} <synthetic>` }]
|
||||||
|
]));
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
let document;
|
||||||
|
class Element {
|
||||||
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.isConnected = true; this.text = ""; }
|
||||||
|
set textContent(value) { this.text = value; this.children = []; }
|
||||||
|
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||||
|
append(...nodes) { for (const node of nodes) { node.parent = this; this.children.push(node); } }
|
||||||
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
|
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||||
|
focus() { document.activeElement = this; }
|
||||||
|
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
|
||||||
|
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
|
||||||
|
}
|
||||||
|
const host = new Element("host");
|
||||||
|
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
|
||||||
|
getElementById: id => id === "opportunities-extension-modal" ? host : null,
|
||||||
|
listeners: {}, addEventListener(key, fn) { this.listeners[key] = fn; } };
|
||||||
|
const app = loadApplication("content", { document, window: {} });
|
||||||
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
app.authSessionState.principal = "synthetic@example.invalid";
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [] });
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [] });
|
||||||
|
app.platformContentMessages.sendRuntimeMessage = () => assert.fail("Form must not send requests");
|
||||||
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||||
|
const nodes = () => walk(host);
|
||||||
|
const form = () => nodes().find(node => node.tag === "form");
|
||||||
|
return { app, host, document, nodes, form };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("only plus opens the nine-field form; a missing workload stays disabled without a save operation", () => {
|
||||||
|
const h = setup();
|
||||||
|
const badge = h.app.opportunityWorkloadActions.createBadge("A");
|
||||||
|
assert.equal(badge.textContent, "0+");
|
||||||
|
badge.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
const plus = badge.children.find(node => node.tag === "button");
|
||||||
|
assert.equal(plus.textContent, "+");
|
||||||
|
assert.equal(plus.attributes["aria-haspopup"], "dialog");
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 1);
|
||||||
|
assert.equal(h.host.children[0].attributes.role, "dialog");
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.name).map(node => node.name), ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity", "notes"]);
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "notes").tag, "textarea");
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "startDate").type, "date");
|
||||||
|
assert.equal(h.document.activeElement.title, "Close");
|
||||||
|
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
|
||||||
|
assert.equal(h.form().attributes["data-state"], "error");
|
||||||
|
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, true);
|
||||||
|
let prevented = false;
|
||||||
|
h.form().listeners.submit({ preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
h.nodes().find(node => node.textContent === "Cancel").click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.document.activeElement, plus);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("form traps focus, handles Escape, replaces rather than duplicates, and resets its fields", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.contentModal.ensureEscapeKeyHandler();
|
||||||
|
const first = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
|
||||||
|
first.click();
|
||||||
|
h.nodes().find(node => node.name === "notes").value = "Draft";
|
||||||
|
const second = h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button");
|
||||||
|
second.click();
|
||||||
|
assert.equal(h.host.children.length, 1);
|
||||||
|
assert.equal(h.nodes().find(node => node.name === "notes").value, undefined);
|
||||||
|
const cancel = h.nodes().find(node => node.textContent === "Cancel");
|
||||||
|
cancel.focus();
|
||||||
|
let prevented = false;
|
||||||
|
h.host.children[0].listeners.keydown({ key: "Tab", preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
assert.equal(h.document.activeElement.title, "Close");
|
||||||
|
h.document.listeners.keydown({ key: "Escape", preventDefault() {} });
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.document.activeElement, second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SALES_REP, loading/unknown Actions and stale triggers cannot open the modal", () => {
|
||||||
|
const h = setup();
|
||||||
|
const plus = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => false;
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A"), null);
|
||||||
|
h.app.currentUser.allowsCrmActivities = () => true;
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "pending" }] });
|
||||||
|
plus.click();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").children.some(node => node.tag === "button"), false);
|
||||||
|
h.app.authSessionState.principal = "";
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("B").children.some(node => node.tag === "button"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resetting Actions closes an open form without leaving an old opportunity context", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
|
||||||
|
h.app.opportunityWorkloadActions.reset();
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action header shows the selected opportunity customer, name and formatted close date safely", () => {
|
||||||
|
const h = setup();
|
||||||
|
h.app.opportunitiesTableState.opportunitiesTableState.items = [
|
||||||
|
{ OptyNumber: "A", Name: "<script>synthetic</script>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" },
|
||||||
|
{ OptyNumber: "B", Name: "Opportunity B", CustomerAccount: { PartyUniqueName: "Customer B" }, EffectiveDate: null }
|
||||||
|
];
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<script>synthetic</script>", "30/11/2026"]);
|
||||||
|
assert.equal(h.nodes().some(node => node.tag === "script"), false);
|
||||||
|
h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button").click();
|
||||||
|
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer B", "Opportunity B", "-"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function optionsSetup(products = [{ RevnId: "123", WorkloadName_c: "Synthetic workload" }]) {
|
||||||
|
const h = setup();
|
||||||
|
h.calls = [];
|
||||||
|
h.app.productsContentState.opptyProductsByNumber.set("A", { items: products });
|
||||||
|
h.app.opportunityWorkloadActions.getSummary = () => ({ state: "missing" });
|
||||||
|
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
||||||
|
h.app.debugContentView.renderDebugPanel = () => {};
|
||||||
|
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise((resolve, reject) => h.calls.push({ message, resolve, reject }));
|
||||||
|
h.field = name => h.nodes().find(node => node.name === name);
|
||||||
|
h.open = () => h.app.workloadActionForm.open("A", null);
|
||||||
|
h.workload = () => h.nodes().find(node => node.id === "opportunities-extension-action-form-workload");
|
||||||
|
h.completeLookup = () => h.calls.find(call => call.message.type.endsWith("requestActionFindLookup")).resolve({ ok: true, lookups: lookupData(), debugRequests: [] });
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("both options requests must succeed before fields enable; email and lookup codes stay distinct from labels", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
assert.equal(h.form().attributes["aria-busy"], "true");
|
||||||
|
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.deepEqual(h.calls.map(call => call.message.type), ["opportunitiesExtension.requestActionFindLookup", "opportunitiesExtension.requestActionTeamMembers"]);
|
||||||
|
const team = h.calls[1];
|
||||||
|
assert.equal(team.message.workloadId, "123");
|
||||||
|
for (const call of h.calls) assert.equal(call.message.authorization.value, "synthetic-wwb");
|
||||||
|
team.resolve({ ok: true, members: [{ email: "owner@example.invalid" }], debugRequests: [] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").disabled, true, "a partial result cannot enable the form");
|
||||||
|
h.completeLookup();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
assert.equal(h.form().attributes["aria-busy"], "false");
|
||||||
|
assert.equal(h.field("owner").tag, "select");
|
||||||
|
assert.equal(h.field("owner").children[1].textContent, "owner@example.invalid");
|
||||||
|
assert.equal(h.field("owner").children[1].value, "owner@example.invalid");
|
||||||
|
assert.equal(h.field("owner").value, "", "unmatched user is not replaced by the first owner");
|
||||||
|
for (const [name, key] of [["role", "ROLE"], ["action", "NAME"], ["team", "TEAM"], ["status", "STATUS"], ["complexity", "COMPLEXITY"]]) {
|
||||||
|
const field = h.field(name);
|
||||||
|
assert.equal(field.tag, "select");
|
||||||
|
assert.equal(field.disabled, false);
|
||||||
|
assert.equal(field.children[1].value, `${key}_CODE`);
|
||||||
|
assert.equal(field.children[1].textContent, `${key} <synthetic>`);
|
||||||
|
assert.equal(field.children[1].children.length, 0);
|
||||||
|
}
|
||||||
|
assert.equal(h.document.activeElement.id, `${h.field("owner").id}-search`);
|
||||||
|
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, false);
|
||||||
|
let prevented = false;
|
||||||
|
h.form().listeners.submit({ preventDefault() { prevented = true; } });
|
||||||
|
assert.equal(prevented, true);
|
||||||
|
assert.equal(h.calls.length, 2, "no save request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action defaults use current user and lookup labels; only Notes is optional", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
const identity = h.app.currentUser.ensure();
|
||||||
|
h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", roles: ["SIMPLE_USER"] } });
|
||||||
|
await identity;
|
||||||
|
assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid");
|
||||||
|
h.calls.length = 0;
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const data = lookupData();
|
||||||
|
data.WWB_ACTION_ROLE = [{ code: "CLOUD_ARCHITECT", name: "Cloud Architect" }];
|
||||||
|
data.WWB_ACTION_NAME = [{ code: "SUCCESS_PLAN_CONSUM_RVW", name: "Success Plan & Consumption review" }];
|
||||||
|
data.WWB_ACTION_STATUS = [{ code: "COMPLETE", name: "Complete" }];
|
||||||
|
h.calls[0].resolve({ ok: true, lookups: data });
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "other@example.invalid" }, { email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
for (const [name, value, label] of [
|
||||||
|
["owner", "owner@example.invalid", "owner@example.invalid"],
|
||||||
|
["role", "CLOUD_ARCHITECT", "Cloud Architect"],
|
||||||
|
["action", "SUCCESS_PLAN_CONSUM_RVW", "Success Plan & Consumption review"],
|
||||||
|
["status", "COMPLETE", "Complete"]
|
||||||
|
]) {
|
||||||
|
assert.equal(h.field(name).value, value);
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${h.field(name).id}-search`).value, label);
|
||||||
|
}
|
||||||
|
for (const name of ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity"]) {
|
||||||
|
const field = h.field(name);
|
||||||
|
assert.equal(field.required, true);
|
||||||
|
assert.equal(field.attributes["aria-required"], "true");
|
||||||
|
assert.ok(field.parent.children[0].textContent.endsWith("(required)"));
|
||||||
|
if (field.tag === "select") {
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${field.id}-search`).required, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(h.field("team").value, "");
|
||||||
|
assert.equal(h.field("complexity").value, "");
|
||||||
|
assert.equal(h.field("notes").required, false);
|
||||||
|
assert.equal(h.field("notes").parent.children[0].textContent, "Notes (optional)");
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Save validates, prevents duplicate POSTs, preserves errors and reports success", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.app.productsContentJobs.queueProductsTableRender = () => {};
|
||||||
|
const notifications = [];
|
||||||
|
h.app.opportunitiesLinks.showCopyConfirmation = (...args) => notifications.push(args);
|
||||||
|
h.app.currentUser.getUserId = () => "creator@example.invalid";
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
const submit = () => h.form().listeners.submit({ preventDefault() {} });
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.equal(h.nodes().find(node => node.id === `${h.field("owner").id}-search`).attributes["aria-invalid"], "true");
|
||||||
|
for (const name of ["owner", "role", "action", "team", "status", "complexity"]) h.field(name).value = h.field(name).children[1].value;
|
||||||
|
h.field("startDate").value = "2026-09-14";
|
||||||
|
h.field("endDate").value = "2026-09-13";
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 2);
|
||||||
|
assert.equal(h.field("endDate").attributes["aria-invalid"], "true");
|
||||||
|
h.field("endDate").value = "2026-09-15";
|
||||||
|
h.field("notes").value = "My note";
|
||||||
|
const first = submit();
|
||||||
|
await flush();
|
||||||
|
await submit();
|
||||||
|
assert.equal(h.calls.length, 3);
|
||||||
|
const call = h.calls[2];
|
||||||
|
assert.equal(call.message.type, "opportunitiesExtension.requestMgmtAction");
|
||||||
|
assert.equal(call.message.authorization.value, "synthetic-wwb");
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(call.message.payload)), {
|
||||||
|
parentType: "WORKLOAD", parentId: "123", action: {
|
||||||
|
ownerEmail: "owner@example.invalid", role: "ROLE_CODE", name: "NAME_CODE", team: "TEAM_CODE",
|
||||||
|
tags: "", startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
status: "STATUS_CODE", complexity: "COMPLEXITY_CODE", notes: ["My note"], objective: "",
|
||||||
|
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
call.resolve({ ok: false, error: "HTTP 403" });
|
||||||
|
await first;
|
||||||
|
assert.equal(h.field("notes").value, "My note");
|
||||||
|
assert.deepEqual(notifications[0], ["HTTP 403", true]);
|
||||||
|
h.field("notes").value = "";
|
||||||
|
const second = submit();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls[3].message.payload.action.notes.length, 0);
|
||||||
|
h.calls[3].resolve({ ok: true, result: { id: 77, ownerEmail: "synthetic@example.invalid", name: "REVIEW" } });
|
||||||
|
await second;
|
||||||
|
assert.equal(h.host.children.length, 0);
|
||||||
|
assert.deepEqual(notifications[1], ["Action saved successfully."]);
|
||||||
|
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("autocomplete searches labels and codes, selects by keyboard and restores committed values", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const data = lookupData();
|
||||||
|
data.WWB_ACTION_ROLE = [{ code: "CA", name: "Cloud Architect" }, { code: "SE", name: "Solution Engineer" }];
|
||||||
|
h.calls[0].resolve({ ok: true, lookups: data });
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
const input = h.nodes().find(node => node.id === `${h.field("role").id}-search`);
|
||||||
|
const list = h.nodes().find(node => node.id === input.attributes["aria-controls"]);
|
||||||
|
list.style = {};
|
||||||
|
input.closest = () => ({ getBoundingClientRect: () => ({ top: 20, bottom: 600 }) });
|
||||||
|
input.getBoundingClientRect = () => ({ top: 100, bottom: 138 });
|
||||||
|
const key = key => input.listeners.keydown({ key, preventDefault() {}, stopPropagation() {} });
|
||||||
|
input.value = "architect";
|
||||||
|
input.listeners.input();
|
||||||
|
assert.equal(list.attributes["data-placement"], "below");
|
||||||
|
assert.equal(list.style.maxHeight, "180px");
|
||||||
|
assert.equal(list.children.length, 1);
|
||||||
|
assert.equal(list.children[0].textContent, "Cloud Architect");
|
||||||
|
key("ArrowDown");
|
||||||
|
assert.equal(list.children[0].attributes["aria-selected"], "true");
|
||||||
|
key("Enter");
|
||||||
|
assert.equal(h.field("role").value, "CA");
|
||||||
|
assert.equal(input.value, "Cloud Architect");
|
||||||
|
assert.equal(list.hidden, true);
|
||||||
|
input.value = "not present";
|
||||||
|
input.getBoundingClientRect = () => ({ top: 500, bottom: 538 });
|
||||||
|
input.listeners.input();
|
||||||
|
assert.equal(list.attributes["data-placement"], "above");
|
||||||
|
assert.equal(list.style.maxHeight, "180px");
|
||||||
|
assert.equal(list.textContent, "No results");
|
||||||
|
key("Escape");
|
||||||
|
assert.equal(input.value, "Cloud Architect");
|
||||||
|
input.value = "SE";
|
||||||
|
input.listeners.input();
|
||||||
|
list.children[0].click();
|
||||||
|
assert.equal(h.field("role").value, "SE");
|
||||||
|
assert.equal(input.value, "Solution Engineer");
|
||||||
|
input.value = "free text";
|
||||||
|
input.listeners.blur();
|
||||||
|
assert.equal(input.value, "Solution Engineer");
|
||||||
|
assert.equal(h.calls.length, 2, "searching never issues requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workload autocomplete uses the selected RevnId and disables metadata search during loading", async () => {
|
||||||
|
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }]);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const input = h.nodes().find(node => node.id === `${h.workload().id}-search`);
|
||||||
|
input.value = "second";
|
||||||
|
input.listeners.input();
|
||||||
|
input.listeners.keydown({ key: "Enter", preventDefault() {} });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.workload().value, "456");
|
||||||
|
assert.equal(h.calls[1].message.workloadId, "456");
|
||||||
|
assert.ok(h.nodes().filter(node => node.attributes.role === "combobox" && node !== input).every(node => node.disabled));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple workloads require selection, share the lookup and ignore an older member response", async () => {
|
||||||
|
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }, { RevnId: "123" }]);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 1);
|
||||||
|
assert.equal(h.form().attributes["data-state"], "waiting");
|
||||||
|
const workload = h.workload();
|
||||||
|
assert.equal(workload.children.length, 3, "duplicate revenue IDs do not duplicate workloads");
|
||||||
|
assert.equal(workload.parent.hidden, false);
|
||||||
|
workload.value = "123";
|
||||||
|
workload.listeners.change();
|
||||||
|
await flush();
|
||||||
|
workload.value = "456";
|
||||||
|
workload.listeners.change();
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(h.calls.slice(1).map(call => call.message.workloadId), ["123", "456"]);
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[2].resolve({ ok: true, members: [{ email: "second@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "first@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors remain local, register diagnostics under Actions and can be retried without duplicate requests", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: false, error: "Synthetic HTTP 403", debugRequests: [{
|
||||||
|
name: "requestActionTeamMembers", status: "failed", httpStatus: 403, startedAt: "2026-09-28T12:00:00Z",
|
||||||
|
completedAt: "2026-09-28T12:00:01Z", durationMs: 1000, requestHeaders: { "Wwb-Provider-Authorization": "(present)" }
|
||||||
|
}] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "error");
|
||||||
|
assert.equal(h.field("owner").disabled, true);
|
||||||
|
assert.equal(h.field("owner").children[0].textContent, "Unavailable");
|
||||||
|
const retry = h.nodes().find(node => node.textContent === "Retry");
|
||||||
|
assert.equal(retry.hidden, false);
|
||||||
|
assert.equal(h.app.debugState.requestLog.length, 1);
|
||||||
|
assert.equal(h.app.debugContentView.getRequestCategory(h.app.debugState.requestLog[0]), "actions");
|
||||||
|
assert.equal(h.app.debugContentView.getRequestCategory({ name: "requestActionFindLookup" }), "actions");
|
||||||
|
retry.click(); retry.click();
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 4);
|
||||||
|
h.calls[2].resolve({ ok: true, lookups: lookupData() });
|
||||||
|
h.calls[3].resolve({ ok: true, members: [] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.form().attributes["data-state"], "ready");
|
||||||
|
assert.equal(h.field("owner").disabled, true);
|
||||||
|
assert.equal(h.field("owner").children[0].textContent, "No options available");
|
||||||
|
assert.equal(h.field("role").disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing or reopening the form discards late responses and waits for fresh options", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
const previous = h.form();
|
||||||
|
h.app.workloadActionForm.close(false);
|
||||||
|
h.open();
|
||||||
|
await flush();
|
||||||
|
h.completeLookup();
|
||||||
|
h.calls[1].resolve({ ok: true, members: [{ email: "old@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.notEqual(h.form(), previous);
|
||||||
|
assert.equal(h.form().attributes["data-state"], "loading");
|
||||||
|
h.calls[2].resolve({ ok: true, lookups: lookupData() });
|
||||||
|
h.calls[3].resolve({ ok: true, members: [{ email: "fresh@example.invalid" }] });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.field("owner").children[1].value, "fresh@example.invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing before authentication finishes prevents both options requests", async () => {
|
||||||
|
const h = optionsSetup();
|
||||||
|
let finish;
|
||||||
|
const auth = new Promise(resolve => { finish = resolve; });
|
||||||
|
h.app.authContentSession.requestConsumerTokenServiceOnce = () => auth;
|
||||||
|
h.open();
|
||||||
|
h.app.workloadActionForm.close(false);
|
||||||
|
finish({ workloadActions: { value: "synthetic" } });
|
||||||
|
await flush();
|
||||||
|
assert.equal(h.calls.length, 0);
|
||||||
|
});
|
||||||
@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
|
|||||||
});
|
});
|
||||||
|
|
||||||
function contentFixture(send) {
|
function contentFixture(send) {
|
||||||
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } });
|
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(k, v) { this.attributes[k] = v; } }) };
|
||||||
|
document.createElementNS = (_, tag) => document.createElement(tag);
|
||||||
|
const app = loadApplication("content", { document });
|
||||||
app.currentUser.allowsCrmActivities = () => true;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
|
||||||
app.authSessionState.principal = "test@example.invalid";
|
app.authSessionState.principal = "test@example.invalid";
|
||||||
@@ -62,6 +64,85 @@ function contentFixture(send) {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("saved response updates the workload badge, tooltip and matching cache without fetching", async () => {
|
||||||
|
const app = contentFixture(() => assert.fail("Save response must not trigger a GET"));
|
||||||
|
const writes = [];
|
||||||
|
app.activityCache.write = async (...args) => writes.push(args);
|
||||||
|
const products = { items: [{ RevnId: "123" }], cachedAt: 1000 };
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: "456" }] });
|
||||||
|
const action = { id: 77, name: "REVIEW", ownerEmail: "test@example.invalid", revenueId: 123, status: "COMPLETE" };
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", action), true);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("B").actions.length, 0);
|
||||||
|
assert.ok(app.opportunityWorkloadActions.createBadge("A").title.includes("ID: 77"));
|
||||||
|
assert.deepEqual(writes[0].slice(0, 3), ["actions", "123", 1000]);
|
||||||
|
assert.equal(writes[0][3][0].id, 77);
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { action: { ...action, status: "OPEN" } });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions[0].status, "OPEN");
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { actions: [{ ...action, id: 78, ownerEmail: "other@example.invalid" }] });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 2);
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("B", "123", action), false);
|
||||||
|
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved Action uses its email rather than display name for green badge and removes plus", async () => {
|
||||||
|
const app = contentFixture(() => assert.fail("No follow-up GET"));
|
||||||
|
app.activityCache.write = async () => {};
|
||||||
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }] });
|
||||||
|
const saved = { id: 90, owner: "Test User", ownerEmail: " TEST@example.invalid ", name: "REVIEW" };
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", saved);
|
||||||
|
const badge = app.opportunityWorkloadActions.createBadge("A");
|
||||||
|
assert.equal(badge.attributes["data-state"], "matched");
|
||||||
|
assert.equal(badge.textContent, "1");
|
||||||
|
await app.opportunityWorkloadActions.applySavedAction("A", "123", { id: 90, owner: "Test User" }, { ownerEmail: "test@example.invalid" });
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
|
||||||
|
app.authSessionState.principal = "another@example.invalid";
|
||||||
|
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
|
||||||
|
const pending = [];
|
||||||
|
let finishWrite;
|
||||||
|
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||||
|
app.activityCache.read = async () => null;
|
||||||
|
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
|
||||||
|
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
|
||||||
|
let complete = false, sharedComplete = false;
|
||||||
|
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
|
||||||
|
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
|
||||||
|
await flush();
|
||||||
|
assert.equal(pending.length, 4);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
pending.slice(attempt * 4, attempt * 4 + 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
assert.equal(pending.length, 21);
|
||||||
|
assert.equal(complete, false);
|
||||||
|
assert.equal(sharedComplete, false);
|
||||||
|
pending[20]({ ok: true, actions: [], hasNext: false });
|
||||||
|
await flush();
|
||||||
|
assert.equal(complete, false, "cache write still pending");
|
||||||
|
finishWrite();
|
||||||
|
await batch;
|
||||||
|
await shared;
|
||||||
|
assert.equal(complete, true);
|
||||||
|
assert.equal(sharedComplete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
|
||||||
|
const pending = [];
|
||||||
|
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
|
||||||
|
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
|
||||||
|
await flush();
|
||||||
|
app.opportunityWorkloadActions.reset(false);
|
||||||
|
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
|
||||||
|
await batch;
|
||||||
|
assert.equal(pending.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
|
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = contentFixture(async message => {
|
const app = contentFixture(async message => {
|
||||||
@@ -71,18 +152,18 @@ test("actions aggregate all products, share RevnId queries across Reference chan
|
|||||||
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
|
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
|
||||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
assert.equal(calls[0].authorization.value, "synthetic-wwb");
|
assert.equal(calls[0].authorization.value, "synthetic-wwb");
|
||||||
const badge = app.opportunityWorkloadActions.createBadge("A");
|
const badge = app.opportunityWorkloadActions.createBadge("A");
|
||||||
assert.equal(badge.textContent, "\u2705 Act");
|
assert.equal(badge.textContent, "1");
|
||||||
assert.match(badge.title, /<script>safe<\/script>/);
|
assert.match(badge.title, /<script>safe<\/script>/);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 2);
|
assert.equal(calls.length, 2);
|
||||||
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
|
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "\u26d4 Act");
|
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "0");
|
||||||
app.opportunityWorkloadActions.reset(false);
|
app.opportunityWorkloadActions.reset(false);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -100,9 +181,9 @@ test("errors, missing ids and partial empty pages never appear as confirmed abse
|
|||||||
app.productsContentState.opptyProductsByNumber.set("A", products);
|
app.productsContentState.opptyProductsByNumber.set("A", products);
|
||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
|
||||||
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
|
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
|
||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
|
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,15 +198,17 @@ test("Act requires an owner matching tokenrelay principal while tooltip retains
|
|||||||
app.opportunityWorkloadActions.loadProducts(products);
|
app.opportunityWorkloadActions.loadProducts(products);
|
||||||
await flush();
|
await flush();
|
||||||
const badge = () => app.opportunityWorkloadActions.createBadge("A");
|
const badge = () => app.opportunityWorkloadActions.createBadge("A");
|
||||||
assert.equal(badge().textContent, "\u2705 Act");
|
assert.equal(badge().textContent, "2");
|
||||||
|
assert.equal(badge().attributes["data-state"], "matched");
|
||||||
assert.match(badge().title, /Other action/);
|
assert.match(badge().title, /Other action/);
|
||||||
assert.match(badge().title, /My action/);
|
assert.match(badge().title, /My action/);
|
||||||
app.authSessionState.principal = "different@example.invalid";
|
app.authSessionState.principal = "different@example.invalid";
|
||||||
assert.equal(badge().textContent, "\u26d4 Act", "ownerEmail must not determine ownership");
|
assert.equal(badge().textContent, "2");
|
||||||
|
assert.equal(badge().attributes["data-state"], "missing", "ownerEmail must not determine ownership");
|
||||||
assert.match(badge().title, /Other action/);
|
assert.match(badge().title, /Other action/);
|
||||||
assert.match(badge().title, /My action/);
|
assert.match(badge().title, /My action/);
|
||||||
app.authSessionState.principal = "";
|
app.authSessionState.principal = "";
|
||||||
assert.equal(badge().textContent, "Act ?");
|
assert.equal(badge().textContent, "?");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
|
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
|
||||||
@@ -158,7 +241,7 @@ test("actions queue caps concurrency and discards results/queued jobs from close
|
|||||||
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
|
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("product progress triggers Actions for cached products and ignores stale product jobs", async () => {
|
test("product progress triggers Actions only for fresh products and ignores stale product jobs", async () => {
|
||||||
let listener;
|
let listener;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const app = loadApplication("content", {
|
const app = loadApplication("content", {
|
||||||
@@ -173,14 +256,19 @@ test("product progress triggers Actions for cached products and ignores stale pr
|
|||||||
return { ok: true, actions: [], hasNext: false };
|
return { ok: true, actions: [], hasNext: false };
|
||||||
};
|
};
|
||||||
app.productsContentState.currentProductsRequestId = "current";
|
app.productsContentState.currentProductsRequestId = "current";
|
||||||
|
app.productActivities.begin("current", "synthetic");
|
||||||
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
|
||||||
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
||||||
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
|
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
|
||||||
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true };
|
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false };
|
||||||
listener({ ...message, requestId: "old" });
|
listener({ ...message, requestId: "old" });
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls.length, 0);
|
assert.equal(calls.length, 0);
|
||||||
listener(message);
|
listener(message);
|
||||||
await flush();
|
await flush();
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
listener({ ...message, optyNumber: undefined, state: "complete" });
|
||||||
|
await flush();
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
|
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
|
||||||
assert.equal(calls[0].revnId, "123");
|
assert.equal(calls[0].revnId, "123");
|
||||||
|
|||||||
Reference in New Issue
Block a user