Implemented the functionality to add a WL Action

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

View File

@@ -1,11 +1,13 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
let pending = null;
let allowed = false;
let userId = "";
function ensure() {
if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
return allowed;
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
}
return pending;
}
return { ensure, allowsCrmActivities: () => allowed };
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
}

View File

@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...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 requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -123,7 +126,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
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 {
start, contracts,

View File

@@ -8,6 +8,9 @@ function registerBackgroundMessages({
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -102,6 +105,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
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) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -390,7 +390,20 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes
});
const workloadActionForm = createWorkloadActionForm({
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
saveAction: (...args) => workloadActionOptions.save(...args),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getCurrentUserId: () => currentUser.getUserId(),
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID,

View File

@@ -23,7 +23,7 @@ function createDebugContentView({
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
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 === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";

View File

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

View File

@@ -287,9 +287,13 @@
.opportunities-extension-action-form-context dt { font-weight: 600; }
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
.opportunities-extension-action-form-field[hidden] { display: none; }
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
.opportunities-extension-action-form-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field select,
.opportunities-extension-action-form-field textarea {
box-sizing: border-box;
min-width: 0;
@@ -303,6 +307,46 @@
background: #fff;
}
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-field select { height: 38px; }
.opportunities-extension-action-form-field select[hidden] { display: none; }
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
.opportunities-extension-action-combobox input { height: 38px; }
.opportunities-extension-action-options {
position: absolute;
z-index: 10;
top: calc(100% + 4px);
left: 0;
right: 0;
box-sizing: border-box;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid #8f8983;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
background: #fff;
color: #292725;
}
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
.opportunities-extension-action-options[hidden] { display: none; }
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
.opportunities-extension-action-option:hover,
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
}
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
}
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button {
border: 1px solid #8f8983;
@@ -323,7 +367,9 @@
border-color: #756f68;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }

View File

@@ -1,4 +1,5 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, formatDate, createCloseIcon, environment }) {
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
@@ -7,8 +8,112 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
}
function render(query = "") {
if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
@@ -61,23 +166,52 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form");
// Form-only version: never submit or navigate until the API contract is provided.
form.addEventListener("submit", event => event.preventDefault());
form.noValidate = true;
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
.map(product => [String(product.RevnId), product])).entries()];
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
const workload = element("select");
workload.id = "opportunities-extension-action-form-workload";
workloadField.htmlFor = workload.id;
function option(select, value, label) {
const item = element("option", "", label);
item.value = value;
select.append(item);
}
option(workload, "", "Select workload");
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
workload.value = workloads.length === 1 ? workloads[0][0] : "";
workloadField.hidden = workloads.length <= 1;
workloadField.append(element("span", "", "Workload"), workload);
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
form.append(workloadField, status);
const searchable = new Map();
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
const fields = [];
const selects = [];
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
for (const [name, label, type] of [
["team", "Team", "text"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "text"],
["complexity", "Complexity", "text"], ["notes", "Notes", "textarea"]
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
["team", "Team", "select"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "select"],
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
]) {
const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "textarea" ? "textarea" : "input");
if (type !== "textarea") input.type = type;
else input.rows = 5;
const input = element(type === "select" || type === "textarea" ? type : "input");
if (type === "select") selects.push({ input, label, name });
else if (type === "textarea") input.rows = 5;
else input.type = type;
input.disabled = true;
input.required = name !== "notes";
input.setAttribute("aria-required", String(input.required));
input.name = name;
input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id;
if (name === "notes" || name === "team") field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", label), input);
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
if (type === "select") searchable.set(input, searchableSelect(input, field));
form.append(field);
fields.push(input);
}
@@ -85,27 +219,189 @@ function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingActi
const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
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");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
footer.append(retry, save, cancel);
form.append(footer);
dialog.append(header, form);
modal.append(dialog);
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("keydown", event => {
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);
if (!controls.length) { event.preventDefault(); return; }
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
fields[0].focus();
if (!workloadField.hidden) searchable.get(workload).input.focus();
else dismiss.focus();
loadOptions();
}
return { createButton, open, close };
}

View File

@@ -0,0 +1,17 @@
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
async function request(type, workloadId, isCurrent, payload) {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent()) return null;
const authorization = authorizations?.workloadActions;
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
return result;
}
return {
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
};
}

View File

@@ -112,10 +112,39 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|| records.some(record => record?.status === "error" || record?.hasNext);
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";
return { state, actions, records, principal, pending, uncertain };
}
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
const id = String(revnId);
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
&& (action.revenueId == null || String(action.revenueId) === id));
if (!saved.length) return false;
const previous = session.records.get(id);
const actions = [...(previous?.actions || [])];
for (const action of saved) {
// The POST can return a display name in owner, unlike the workload GET.
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
const normalized = { ...action, ownerEmail, owner: ownerEmail };
const index = actions.findIndex(item => String(item.id) === String(action.id));
if (index === -1) actions.push(normalized);
else actions[index] = { ...actions[index], ...normalized };
}
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
completion: Promise.resolve(), resolveCompletion() {} };
session.records.set(id, record);
previous?.resolveCompletion?.();
session.progress.delete(previous);
queueProductsTableRender();
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
return true;
}
function createBadge(optyNumber) {
const summary = getSummary(optyNumber);
if (!summary) return null;
@@ -139,5 +168,5 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge;
}
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary };
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
}

View File

@@ -4,7 +4,7 @@ function createHttpBackgroundJson({
environment
}) {
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 debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -36,6 +36,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;

View File

@@ -0,0 +1,83 @@
function createSpaActionOptions({ fetchJson }) {
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
async function request(message, name, path, normalize) {
const debugRequests = [];
try {
const token = message.authorization?.value;
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await fetchJson(`${baseUrl}/${path()}`, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, name, debugRequests);
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
return { ok: true, ...normalize(result), debugRequests };
} catch (error) {
const entry = debugRequests.at(-1);
if (entry) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
function requestActionTeamMembers(message) {
return request(message, "requestActionTeamMembers", () => {
const id = String(message.workloadId ?? "").trim();
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
}, result => {
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
throw new Error("requestActionTeamMembers returned an invalid collection.");
}
return { members: result.map(item => ({ email: item.email })) };
});
}
function requestActionFindLookup(message) {
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
const lookups = {};
for (const key of lookupKeys) {
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|| typeof item?.name !== "string" || !item.name.trim())) {
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
}
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
}
return { lookups };
});
}
async function requestMgmtAction(message) {
const debugRequests = [];
try {
const token = message.authorization?.value;
const payload = message.payload;
const action = payload?.action;
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
}
for (const key of ["startDate", "endDate"]) {
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
}
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
}

View File

@@ -11,6 +11,9 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",