305 lines
15 KiB
JavaScript
305 lines
15 KiB
JavaScript
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
|
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
|
|
const { document } = environment;
|
|
let active = null;
|
|
function element(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
function close(restoreFocus = true) {
|
|
if (!active) return false;
|
|
if (active.saving) return true;
|
|
const previous = active;
|
|
active = null;
|
|
previous.modal.remove();
|
|
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
|
return true;
|
|
}
|
|
function createButton(optyNumber) {
|
|
const button = element("button", "opportunities-extension-add-action", "+");
|
|
button.type = "button";
|
|
button.title = "Add Action";
|
|
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
|
|
button.setAttribute("aria-haspopup", "dialog");
|
|
button.addEventListener("click", event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
open(optyNumber, button);
|
|
});
|
|
return button;
|
|
}
|
|
function open(optyNumber, trigger) {
|
|
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
|
|
const host = document.getElementById(MODAL_ID);
|
|
if (!host || host.hidden) return;
|
|
close(false);
|
|
const modal = element("section", "opportunities-extension-action-form-modal");
|
|
modal.setAttribute("role", "dialog");
|
|
modal.setAttribute("aria-modal", "true");
|
|
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
|
|
const dialog = element("div", "opportunities-extension-action-form-dialog");
|
|
const header = element("header", "opportunities-extension-account-plans-header");
|
|
const heading = element("div", "opportunities-extension-action-form-heading");
|
|
const title = element("h2", "", "Add Action");
|
|
title.id = "opportunities-extension-action-form-title";
|
|
heading.append(title, element("p", "", String(optyNumber)));
|
|
const opportunity = getOpportunity(optyNumber);
|
|
const details = element("dl", "opportunities-extension-action-form-context");
|
|
for (const [label, value] of [
|
|
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
|
|
["Name", opportunity?.Name],
|
|
["Close Date", formatDate(opportunity?.EffectiveDate)]
|
|
]) {
|
|
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
|
|
}
|
|
heading.append(details);
|
|
const dismiss = element("button", "opportunities-extension-icon-button");
|
|
dismiss.type = "button";
|
|
dismiss.title = "Close";
|
|
dismiss.setAttribute("aria-label", "Close Action form");
|
|
dismiss.append(createCloseIcon());
|
|
dismiss.addEventListener("click", () => close());
|
|
header.append(heading, dismiss);
|
|
const form = element("form", "opportunities-extension-action-form");
|
|
form.noValidate = true;
|
|
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
|
|
.map(product => [String(product.RevnId), product])).entries()];
|
|
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
|
|
const workload = element("select");
|
|
workload.id = "opportunities-extension-action-form-workload";
|
|
workloadField.htmlFor = workload.id;
|
|
function option(select, value, label) {
|
|
const item = element("option", "", label);
|
|
item.value = value;
|
|
select.append(item);
|
|
}
|
|
option(workload, "", "Select workload");
|
|
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
|
|
workload.value = workloads.length === 1 ? workloads[0][0] : "";
|
|
workloadField.hidden = workloads.length <= 1;
|
|
workloadField.append(element("span", "", "Workload"), workload);
|
|
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
|
status.setAttribute("role", "status");
|
|
form.append(workloadField, status);
|
|
const searchable = new Map();
|
|
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
|
|
const fields = [];
|
|
const selects = [];
|
|
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
|
|
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
|
|
for (const [name, label, type] of [
|
|
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
|
|
["team", "Team", "select"], ["startDate", "Start date", "date"],
|
|
["endDate", "End Date", "date"], ["status", "Status", "select"],
|
|
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
|
|
]) {
|
|
const field = element("label", "opportunities-extension-action-form-field");
|
|
const input = element(type === "select" || type === "textarea" ? type : "input");
|
|
if (type === "select") selects.push({ input, label, name });
|
|
else if (type === "textarea") input.rows = 5;
|
|
else input.type = type;
|
|
input.disabled = true;
|
|
input.required = name !== "notes";
|
|
input.setAttribute("aria-required", String(input.required));
|
|
input.name = name;
|
|
input.id = `opportunities-extension-action-form-${name}`;
|
|
field.htmlFor = input.id;
|
|
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
|
|
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
|
|
if (type === "select") searchable.set(input, searchableSelect(input, field));
|
|
form.append(field);
|
|
fields.push(input);
|
|
}
|
|
const footer = element("footer", "opportunities-extension-action-form-footer");
|
|
const save = element("button", "opportunities-extension-action-form-save", "Save");
|
|
save.type = "submit";
|
|
save.disabled = true;
|
|
save.title = "Save Action";
|
|
const retry = element("button", "", "Retry");
|
|
retry.type = "button";
|
|
retry.hidden = true;
|
|
const cancel = element("button", "", "Cancel");
|
|
cancel.type = "button";
|
|
cancel.addEventListener("click", () => close());
|
|
footer.append(retry, save, cancel);
|
|
form.append(footer);
|
|
dialog.append(header, form);
|
|
modal.append(dialog);
|
|
host.append(modal);
|
|
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
|
|
active = view;
|
|
const isOpen = () => active === view && allowsCrmActivities();
|
|
let ready = false;
|
|
function validate() {
|
|
const invalid = [];
|
|
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
|
|
for (const field of fields) {
|
|
let valid = !field.required || !!value(field.name);
|
|
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
|
|
if (field.type === "date") {
|
|
const date = value(field.name);
|
|
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
|
|
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
|
|
if (field.name === "endDate" && value("startDate") > date) valid = false;
|
|
}
|
|
const control = searchable.get(field)?.input || field;
|
|
control.setAttribute("aria-invalid", String(!valid));
|
|
if (!valid) invalid.push(control);
|
|
}
|
|
return { invalid, value };
|
|
}
|
|
form.addEventListener("submit", async event => {
|
|
event.preventDefault();
|
|
if (!ready || !isOpen() || view.saving) return;
|
|
const { invalid, value } = validate();
|
|
if (invalid.length) {
|
|
status.hidden = false;
|
|
status.setAttribute("role", "alert");
|
|
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
|
|
invalid[0].focus();
|
|
return;
|
|
}
|
|
const userId = getCurrentUserId();
|
|
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
|
|
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
|
|
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
|
|
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
|
|
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
|
|
objective: "", createdBy: userId, updatedBy: userId
|
|
} };
|
|
view.saving = true;
|
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
|
|
fields.forEach(field => { field.disabled = true; });
|
|
searchable.forEach(control => control.sync());
|
|
save.textContent = "Saving...";
|
|
form.setAttribute("aria-busy", "true");
|
|
status.hidden = true;
|
|
try {
|
|
const result = await saveAction(payload, isOpen);
|
|
if (!result) return;
|
|
view.saving = false;
|
|
close();
|
|
try {
|
|
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
|
|
} catch {
|
|
notify("Action saved, but the local badge could not be updated.", true);
|
|
return;
|
|
}
|
|
notify("Action saved successfully.");
|
|
} catch (error) {
|
|
status.hidden = false;
|
|
status.setAttribute("role", "alert");
|
|
status.textContent = error.message || "Unable to save Action.";
|
|
notify(status.textContent, true);
|
|
} finally {
|
|
view.saving = false;
|
|
if (active === view) {
|
|
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
|
|
fields.forEach(field => { field.disabled = false; });
|
|
searchable.forEach(control => control.sync());
|
|
save.textContent = "Save";
|
|
form.setAttribute("aria-busy", "false");
|
|
}
|
|
}
|
|
});
|
|
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
|
|
async function loadOptions(refresh = false) {
|
|
const id = workload.value;
|
|
if (!isOpen() || (view.loadingId === id && !refresh)) return;
|
|
const generation = ++view.generation;
|
|
const isCurrent = () => isOpen() && view.generation === generation;
|
|
ready = false;
|
|
save.disabled = true;
|
|
retry.hidden = true;
|
|
status.hidden = false;
|
|
status.setAttribute("role", "status");
|
|
fields.forEach(input => { input.disabled = true; });
|
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
|
|
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
|
|
syncSearch();
|
|
if (!workloads.length) {
|
|
form.setAttribute("data-state", "error");
|
|
form.setAttribute("aria-busy", "false");
|
|
status.setAttribute("role", "alert");
|
|
status.textContent = "No product with a valid RevnId is available for this opportunity.";
|
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
|
syncSearch();
|
|
return;
|
|
}
|
|
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
|
|
if (!workloads.some(([value]) => value === id)) {
|
|
view.loadingId = null;
|
|
form.setAttribute("data-state", "waiting");
|
|
form.setAttribute("aria-busy", "false");
|
|
status.textContent = "Select a workload to load Action options.";
|
|
return;
|
|
}
|
|
view.loadingId = id;
|
|
form.setAttribute("data-state", "loading");
|
|
form.setAttribute("aria-busy", "true");
|
|
status.textContent = "Loading Action options...";
|
|
try {
|
|
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
|
|
if (!isCurrent()) return;
|
|
if (lookup.error || team.error) throw lookup.error || team.error;
|
|
const values = selects.map(({ name }) => name === "owner"
|
|
? team.result.members.map(({ email }) => ({ code: email, name: email }))
|
|
: lookup.result.lookups[lookupKeys[name]]);
|
|
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
|
|
action: "Success Plan & Consumption review", status: "Complete" };
|
|
const normalize = value => String(value ?? "").trim().toLowerCase();
|
|
for (const [index, { input, label, name }] of selects.entries()) {
|
|
input.textContent = "";
|
|
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
|
|
for (const item of values[index]) option(input, item.code, item.name);
|
|
const match = defaults[name] && values[index].find(item =>
|
|
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
|
|
input.value = match ? match.code : "";
|
|
}
|
|
fields.forEach(input => { input.disabled = false; });
|
|
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
|
|
form.setAttribute("data-state", "ready");
|
|
ready = true;
|
|
save.disabled = false;
|
|
status.hidden = true;
|
|
syncSearch();
|
|
if (document.activeElement === dismiss) {
|
|
const first = fields.find(field => !field.disabled);
|
|
(searchable.get(first)?.input || first)?.focus();
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
form.setAttribute("data-state", "error");
|
|
status.setAttribute("role", "alert");
|
|
status.textContent = error.message || "Unable to load Action options.";
|
|
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
|
|
syncSearch();
|
|
retry.hidden = false;
|
|
} finally {
|
|
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
|
|
}
|
|
}
|
|
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
|
|
workload.addEventListener("change", () => loadOptions());
|
|
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
|
modal.addEventListener("keydown", event => {
|
|
if (event.key !== "Tab") return;
|
|
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
|
|
.map(control => searchable.get(control)?.input || control)
|
|
.filter(control => !control.disabled && !control.hidden);
|
|
const index = controls.indexOf(document.activeElement);
|
|
if (!controls.length) { event.preventDefault(); return; }
|
|
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
|
|
event.preventDefault();
|
|
controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
|
}
|
|
});
|
|
if (!workloadField.hidden) searchable.get(workload).input.focus();
|
|
else dismiss.focus();
|
|
loadOptions();
|
|
}
|
|
return { createButton, open, close };
|
|
}
|