Implemented the functionality to add a WL Action
This commit is contained in:
22
README.md
22
README.md
@@ -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
|
||||||
|
|||||||
108
dist/chromium/background.js
vendored
108
dist/chromium/background.js
vendored
@@ -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,
|
||||||
|
|||||||
406
dist/chromium/content.js
vendored
406
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
108
dist/firefox/background.js
vendored
108
dist/firefox/background.js
vendored
@@ -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,
|
||||||
|
|||||||
406
dist/firefox/content.js
vendored
406
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -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",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
|
let userId = "";
|
||||||
function ensure() {
|
function ensure() {
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
|
||||||
appendBackgroundRequestLog(response?.debugRequests);
|
appendBackgroundRequestLog(response?.debugRequests);
|
||||||
const roles = response?.user?.roles;
|
const roles = response?.user?.roles;
|
||||||
|
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
|
||||||
allowed = response?.ok === true && Array.isArray(roles)
|
allowed = response?.ok === true && Array.isArray(roles)
|
||||||
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
|
||||||
return allowed;
|
return allowed;
|
||||||
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
|
|||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
return { ensure, allowsCrmActivities: () => allowed };
|
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
|
||||||
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
|
||||||
|
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
|
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
|
||||||
|
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
|
||||||
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
|
||||||
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
|
||||||
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/features/opportunities/workload-action-options.js
Normal file
17
src/features/opportunities/workload-action-options.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
|
||||||
|
async function request(type, workloadId, isCurrent, payload) {
|
||||||
|
const authorizations = await requestConsumerTokenServiceOnce();
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
const authorization = authorizations?.workloadActions;
|
||||||
|
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
|
||||||
|
appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
|
||||||
|
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
|
||||||
|
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
83
src/services/spa/action-options.js
Normal file
83
src/services/spa/action-options.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function createSpaActionOptions({ fetchJson }) {
|
||||||
|
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
|
||||||
|
async function request(message, name, path, normalize) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
const result = await fetchJson(`${baseUrl}/${path()}`, {
|
||||||
|
method: "GET", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
|
||||||
|
}, name, debugRequests);
|
||||||
|
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
|
||||||
|
return { ok: true, ...normalize(result), debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
const entry = debugRequests.at(-1);
|
||||||
|
if (entry) { entry.status = "failed"; entry.error = error.message; }
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestActionTeamMembers(message) {
|
||||||
|
return request(message, "requestActionTeamMembers", () => {
|
||||||
|
const id = String(message.workloadId ?? "").trim();
|
||||||
|
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
|
||||||
|
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
|
||||||
|
}, result => {
|
||||||
|
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
|
||||||
|
throw new Error("requestActionTeamMembers returned an invalid collection.");
|
||||||
|
}
|
||||||
|
return { members: result.map(item => ({ email: item.email })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestActionFindLookup(message) {
|
||||||
|
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
|
||||||
|
const lookups = {};
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|
||||||
|
|| typeof item?.name !== "string" || !item.name.trim())) {
|
||||||
|
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
|
||||||
|
}
|
||||||
|
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
|
||||||
|
}
|
||||||
|
return { lookups };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function requestMgmtAction(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
const token = message.authorization?.value;
|
||||||
|
const payload = message.payload;
|
||||||
|
const action = payload?.action;
|
||||||
|
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
|
||||||
|
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
|
||||||
|
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
|
||||||
|
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
|
||||||
|
}
|
||||||
|
for (const key of ["startDate", "endDate"]) {
|
||||||
|
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
|
||||||
|
}
|
||||||
|
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
|
||||||
|
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
|
||||||
|
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) {
|
||||||
|
error.debugRequests = debugRequests;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
for (const entry of debugRequests) {
|
||||||
|
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
|
||||||
|
}
|
||||||
@@ -11,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",
|
||||||
|
|||||||
110
tests/actionOptions.test.cjs
Normal file
110
tests/actionOptions.test.cjs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { loadBackground, response } = require("./support/characterization.cjs");
|
||||||
|
const lookups = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
|
||||||
|
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} label` }]
|
||||||
|
]));
|
||||||
|
const teamType = "opportunitiesExtension.requestActionTeamMembers";
|
||||||
|
const lookupType = "opportunitiesExtension.requestActionFindLookup";
|
||||||
|
|
||||||
|
test("Save sends the WWB POST, accepts empty success and never retries errors", async () => {
|
||||||
|
const payload = { parentType: "WORKLOAD", parentId: "123", action: {
|
||||||
|
ownerEmail: "owner@example.invalid", role: "CA", name: "REVIEW", team: "TEAM", tags: "",
|
||||||
|
startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
|
||||||
|
status: "COMPLETE", complexity: "LOW", notes: [], objective: "",
|
||||||
|
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
|
||||||
|
} };
|
||||||
|
for (const status of [200, 201, 204, 403, 500]) {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family: "chrome", fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return status === 204 ? new Response(null, { status }) : response({ id: 123 }, status);
|
||||||
|
} });
|
||||||
|
const result = await h.dispatch({ type: "opportunitiesExtension.requestMgmtAction", payload, authorization: { value: "secret-wwb" } });
|
||||||
|
assert.equal(result.ok, status < 300, `${status}: ${result.error}`);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(calls[0].url.endsWith("/wwb/mgmt/actions"));
|
||||||
|
assert.equal(calls[0].options.method, "POST");
|
||||||
|
assert.equal(calls[0].options.headers["Wwb-Provider-Authorization"], "secret-wwb");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
|
||||||
|
assert.ok(!JSON.stringify(result).includes("secret-wwb"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const family of ["chrome", "firefox"]) {
|
||||||
|
test(`${family}: Action options use dynamic RevnId, WWB-TS and masked GET diagnostics`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
if (url.endsWith("consumerTokenService")) return response([
|
||||||
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "summary-token" } },
|
||||||
|
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "wrong-token" } },
|
||||||
|
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-wwb" } }
|
||||||
|
]);
|
||||||
|
return response(url.includes("teamMembers") ? [{ email: "owner@example.invalid" }] : lookups());
|
||||||
|
} });
|
||||||
|
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||||
|
const authorization = auth.authorizations.workloadActions;
|
||||||
|
const team = await h.dispatch({ type: teamType, workloadId: "987654", authorization, url: "https://example.invalid" });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization });
|
||||||
|
assert.equal(team.ok, true);
|
||||||
|
assert.deepEqual(team.members, [{ email: "owner@example.invalid" }]);
|
||||||
|
assert.deepEqual(lookup.lookups, lookups());
|
||||||
|
const base = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
|
||||||
|
assert.equal(calls[1].url, `${base}/987654/teamMembers?includeLeads=true`);
|
||||||
|
assert.equal(calls[2].url, `${base}/fndLookup`);
|
||||||
|
for (const call of calls.slice(1)) {
|
||||||
|
assert.equal(call.options.method, "GET");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
assert.deepEqual({ ...call.options.headers }, { Accept: "application/json", "Wwb-Provider-Authorization": "synthetic-wwb" });
|
||||||
|
}
|
||||||
|
for (const [result, name] of [[team, "requestActionTeamMembers"], [lookup, "requestActionFindLookup"]]) {
|
||||||
|
const log = result.debugRequests[0];
|
||||||
|
assert.equal(log.name, name);
|
||||||
|
assert.equal(log.status, "success");
|
||||||
|
assert.equal(log.httpStatus, 200);
|
||||||
|
assert.ok(log.durationMs >= 0);
|
||||||
|
assert.equal(log.requestHeaders["Wwb-Provider-Authorization"], "(present)");
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-wwb|wrong-token/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("invalid workload IDs and missing authorization never fetch or accept an arbitrary target", async () => {
|
||||||
|
const h = loadBackground({ fetch: () => assert.fail("No network expected") });
|
||||||
|
for (const workloadId of [undefined, "", "abc", "12/../fndLookup", "12?other=1"]) {
|
||||||
|
assert.equal((await h.dispatch({ type: teamType, workloadId, authorization: { value: "synthetic" } })).ok, false);
|
||||||
|
}
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
assert.equal((await h.dispatch({ type, workloadId: "123" })).ok, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Action options require exactly HTTP 200 and valid collections and preserve failed debug entries", async () => {
|
||||||
|
for (const type of [teamType, lookupType]) {
|
||||||
|
const valid = type === teamType ? [{ email: "owner@example.invalid" }] : lookups();
|
||||||
|
for (const fetch of [
|
||||||
|
async () => response(valid, 201), async () => response({}, 401), async () => response({}, 403),
|
||||||
|
async () => { throw new Error("Synthetic network failure"); },
|
||||||
|
async () => new Response("<html>Unexpected session page</html>"),
|
||||||
|
async () => response(type === teamType ? [{ name: "Missing email" }] : { ...lookups(), WWB_ACTION_NAME: null })
|
||||||
|
]) {
|
||||||
|
const h = loadBackground({ fetch });
|
||||||
|
const result = await h.dispatch({ type, workloadId: "123", authorization: { value: "synthetic-secret" } });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.debugRequests.length, 1);
|
||||||
|
assert.equal(result.debugRequests[0].status, "failed");
|
||||||
|
assert.ok(result.error);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty option lists are valid responses, not invented defaults", async () => {
|
||||||
|
const empty = Object.fromEntries(Object.keys(lookups()).map(key => [key, []]));
|
||||||
|
const h = loadBackground({ fetch: async url => response(url.includes("teamMembers") ? [] : empty) });
|
||||||
|
const members = await h.dispatch({ type: teamType, workloadId: "123", authorization: { value: "synthetic" } });
|
||||||
|
const lookup = await h.dispatch({ type: lookupType, authorization: { value: "synthetic" } });
|
||||||
|
assert.deepEqual(members.members, []);
|
||||||
|
assert.deepEqual(lookup.lookups, empty);
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user