Implemented the functionality to add a WL Action

This commit is contained in:
2026-09-28 17:32:32 -03:00
parent 1a3decbeb6
commit a182609415
23 changed files with 1981 additions and 79 deletions

View File

@@ -81,10 +81,24 @@ finish in the background and update their related activities.
## Workload Actions ## Workload Actions
The missing Act badge includes a separate plus control that opens an Action form The missing Act badge includes a separate plus control that opens an Action form
with Team, Start date, End Date, Status, Complexity and Notes. This is currently with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
UI-only: Save is disabled and the form never submits a request. Cancel or Opening the form loads team members and action lookups using the WWB-TS token.
Escape dismisses the form. Creation options and API integration are pending; Team members use the product RevnId; when multiple workloads are available, the
the form is unavailable to SALES_REP users, like the other Action features. 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 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 activities. They filter the loaded data without issuing requests and combine with

View File

@@ -14,6 +14,9 @@ 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",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache", invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
@@ -653,6 +656,91 @@ 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/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() { async function requestCurrentUser() {
@@ -861,7 +949,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;
@@ -893,6 +981,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;
@@ -1747,6 +1836,9 @@ function registerBackgroundMessages({
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions, requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestCurrentUser, requestCurrentUser,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
@@ -1841,6 +1933,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction].includes(message.type)) {
const request = message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load Action options.",
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.",
@@ -2031,6 +2132,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);
@@ -2039,7 +2143,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, 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,

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,9 @@ 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",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache", invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
@@ -653,6 +656,91 @@ 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/spa/current-user.js // Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) { function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() { async function requestCurrentUser() {
@@ -861,7 +949,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;
@@ -893,6 +981,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;
@@ -1747,6 +1836,9 @@ function registerBackgroundMessages({
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions, requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestCurrentUser, requestCurrentUser,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
@@ -1841,6 +1933,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction].includes(message.type)) {
const request = message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load Action options.",
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.",
@@ -2031,6 +2132,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);
@@ -2039,7 +2143,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, 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,

File diff suppressed because one or more lines are too long

View File

@@ -41,6 +41,7 @@ module.exports = {
"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-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",
@@ -84,6 +85,7 @@ 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/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",

View File

@@ -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 };
} }

View File

@@ -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);
@@ -123,7 +126,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args); const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome; const runtimeApi = environment.browser || environment.chrome;
function start() { function start() {
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi }); startBackgroundApplication({ messageTypes, 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,

View File

@@ -8,6 +8,9 @@ function registerBackgroundMessages({
requestInstallBase, requestInstallBase,
requestCustomerServiceRequest, requestCustomerServiceRequest,
requestWlActions, requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestCurrentUser, requestCurrentUser,
requestConsumerTokenService, requestConsumerTokenService,
requestConsumptionTrend, requestConsumptionTrend,
@@ -102,6 +105,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; return true;
} }
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction].includes(message.type)) {
const request = message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load Action options.",
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.",

View File

@@ -390,7 +390,20 @@ 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({ 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)), getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,

View File

@@ -23,7 +23,7 @@ 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 (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";

View File

@@ -13,7 +13,7 @@ function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSum
container.className = "opportunities-extension-badge-filters"; container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [ for (const [kind, title, options] of [
["sr", "Service Request", ["matched", "under4", "missing"]], ["sr", "Service Request", ["matched", "under4", "missing"]],
["actions", "Act", ["matched", "missing"]] ["actions", "Action", ["matched", "missing"]]
]) { ]) {
const section = environment.document.createElement("section"); const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter"; section.className = "opportunities-extension-activity-filter";

View File

@@ -287,9 +287,13 @@
.opportunities-extension-action-form-context dt { font-weight: 600; } .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-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 { 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-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; } .opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input, .opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field select,
.opportunities-extension-action-form-field textarea { .opportunities-extension-action-form-field textarea {
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
@@ -303,6 +307,46 @@
background: #fff; background: #fff;
} }
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; } .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 { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button { .opportunities-extension-action-form-footer button {
border: 1px solid #8f8983; border: 1px solid #8f8983;
@@ -323,7 +367,9 @@
border-color: #756f68; 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 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-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; } .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) { @media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); } .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }

View File

@@ -1,4 +1,5 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, formatDate, createCloseIcon, environment }) { function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
const { document } = environment; const { document } = environment;
let active = null; let active = null;
function element(tag, className, text) { function element(tag, className, text) {
@@ -7,8 +8,112 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
if (text !== undefined) node.textContent = text; if (text !== undefined) node.textContent = text;
return node; 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) { function close(restoreFocus = true) {
if (!active) return false; if (!active) return false;
if (active.saving) return true;
const previous = active; const previous = active;
active = null; active = null;
previous.modal.remove(); previous.modal.remove();
@@ -61,23 +166,52 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
dismiss.addEventListener("click", () => close()); dismiss.addEventListener("click", () => close());
header.append(heading, dismiss); header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form"); const form = element("form", "opportunities-extension-action-form");
// Form-only version: never submit or navigate until the API contract is provided. form.noValidate = true;
form.addEventListener("submit", event => event.preventDefault()); 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 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 [ for (const [name, label, type] of [
["team", "Team", "text"], ["startDate", "Start date", "date"], ["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
["endDate", "End Date", "date"], ["status", "Status", "text"], ["team", "Team", "select"], ["startDate", "Start date", "date"],
["complexity", "Complexity", "text"], ["notes", "Notes", "textarea"] ["endDate", "End Date", "date"], ["status", "Status", "select"],
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
]) { ]) {
const field = element("label", "opportunities-extension-action-form-field"); const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "textarea" ? "textarea" : "input"); const input = element(type === "select" || type === "textarea" ? type : "input");
if (type !== "textarea") input.type = type; if (type === "select") selects.push({ input, label, name });
else input.rows = 5; 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.name = name;
input.id = `opportunities-extension-action-form-${name}`; input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id; field.htmlFor = input.id;
if (name === "notes" || name === "team") field.className += " opportunities-extension-action-form-wide"; if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", label), input); field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
if (type === "select") searchable.set(input, searchableSelect(input, field));
form.append(field); form.append(field);
fields.push(input); fields.push(input);
} }
@@ -85,27 +219,189 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
const save = element("button", "opportunities-extension-action-form-save", "Save"); const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit"; save.type = "submit";
save.disabled = true; save.disabled = true;
save.title = "Gravacao ainda nao disponivel"; save.title = "Save Action";
const retry = element("button", "", "Retry");
retry.type = "button";
retry.hidden = true;
const cancel = element("button", "", "Cancel"); const cancel = element("button", "", "Cancel");
cancel.type = "button"; cancel.type = "button";
cancel.addEventListener("click", () => close()); cancel.addEventListener("click", () => close());
footer.append(save, cancel); footer.append(retry, save, cancel);
form.append(footer); form.append(footer);
dialog.append(header, form); dialog.append(header, form);
modal.append(dialog); modal.append(dialog);
host.append(modal); host.append(modal);
active = { modal, trigger }; 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("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => { modal.addEventListener("keydown", event => {
if (event.key !== "Tab") return; if (event.key !== "Tab") return;
const controls = [dismiss, ...fields, cancel]; 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); 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)) { if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault(); event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
} }
}); });
fields[0].focus(); if (!workloadField.hidden) searchable.get(workload).input.focus();
else dismiss.focus();
loadOptions();
} }
return { createButton, open, close }; return { createButton, open, close };
} }

View 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)
};
}

View File

@@ -112,10 +112,39 @@ 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 }; 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) { function createBadge(optyNumber) {
const summary = getSummary(optyNumber); const summary = getSummary(optyNumber);
if (!summary) return null; if (!summary) return null;
@@ -139,5 +168,5 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (state === "missing") badge.append(createActionButton(String(optyNumber))); if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge; return badge;
} }
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary }; return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
} }

View File

@@ -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;
@@ -36,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;

View 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 };
}

View File

@@ -11,6 +11,9 @@ 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",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser", requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache", invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend", requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",

View 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);
});

View File

@@ -37,7 +37,7 @@ test("activity badge filters precede Status, are accessible and combine without
assert.equal(h.button("sr", "matched").textContent, ""); assert.equal(h.button("sr", "matched").textContent, "");
assert.equal(h.button("actions", "missing").textContent, ""); assert.equal(h.button("actions", "missing").textContent, "");
assert.equal(h.button("actions", "matched").type, "button"); assert.equal(h.button("actions", "matched").type, "button");
assert.equal(h.controls.children[1].children[0].textContent, "Act"); assert.equal(h.controls.children[1].children[0].textContent, "Action");
for (const value of ["matched", "missing"]) { for (const value of ["matched", "missing"]) {
assert.equal(h.button("actions", value).textContent, ""); 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("actions", value).title.startsWith("Whether the Action is registered in Workload Workbench.\n"));

View File

@@ -1,6 +1,10 @@
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));
const lookupData = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} <synthetic>` }]
]));
function setup() { function setup() {
let document; let document;
@@ -31,7 +35,7 @@ function setup() {
return { app, host, document, nodes, form }; return { app, host, document, nodes, form };
} }
test("only plus on the missing Action badge opens a form with six fields and no save operation", () => { test("only plus opens the nine-field form; a missing workload stays disabled without a save operation", () => {
const h = setup(); const h = setup();
const badge = h.app.opportunityWorkloadActions.createBadge("A"); const badge = h.app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "0+"); assert.equal(badge.textContent, "0+");
@@ -43,10 +47,12 @@ test("only plus on the missing Action badge opens a form with six fields and no
plus.click(); plus.click();
assert.equal(h.host.children.length, 1); assert.equal(h.host.children.length, 1);
assert.equal(h.host.children[0].attributes.role, "dialog"); assert.equal(h.host.children[0].attributes.role, "dialog");
assert.deepEqual(h.nodes().filter(node => node.name).map(node => node.name), ["team", "startDate", "endDate", "status", "complexity", "notes"]); 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 === "notes").tag, "textarea");
assert.equal(h.nodes().find(node => node.name === "startDate").type, "date"); assert.equal(h.nodes().find(node => node.name === "startDate").type, "date");
assert.equal(h.document.activeElement.name, "team"); 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); assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, true);
let prevented = false; let prevented = false;
h.form().listeners.submit({ preventDefault() { prevented = true; } }); h.form().listeners.submit({ preventDefault() { prevented = true; } });
@@ -113,3 +119,297 @@ test("Action header shows the selected opportunity customer, name and formatted
h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button").click(); 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", "-"]); 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);
});

View File

@@ -64,6 +64,45 @@ 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 () => { test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
const pending = []; const pending = [];
let finishWrite; let finishWrite;