Loading comcipapic to establish a session for SR requests
This commit is contained in:
@@ -18,7 +18,7 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
||||
clearTimeout(entry.timer);
|
||||
clearTimeout(entry.hideTimer);
|
||||
entry.state = state;
|
||||
const label = key === "sr" ? "Service Requests" : "Actions";
|
||||
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
|
||||
entry.badge.hidden = state === "idle";
|
||||
entry.badge.setAttribute("data-state", state);
|
||||
entry.badge.setAttribute("data-fading", "false");
|
||||
@@ -27,7 +27,8 @@ function createActivityProgress({ MODAL_ID, environment }) {
|
||||
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
|
||||
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
|
||||
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, environment }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
let loadedItems = [];
|
||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
|
||||
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
|
||||
function load(token, version, cacheOptions = null) {
|
||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||
if (status === "ready") {
|
||||
@@ -89,6 +89,7 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
badge.setAttribute("aria-label", description);
|
||||
badge.title = "Whether a Service Request has been created for the opportunities.";
|
||||
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
|
||||
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
|
||||
badge.tabIndex = 0;
|
||||
return badge;
|
||||
}
|
||||
|
||||
237
src/features/opportunities/crm-sr-form.js
Normal file
237
src/features/opportunities/crm-sr-form.js
Normal file
@@ -0,0 +1,237 @@
|
||||
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
function node(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
active = null;
|
||||
previous.modal.remove();
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function createButton(number) {
|
||||
const button = node("button", "opportunities-extension-add-action", "+");
|
||||
button.type = "button";
|
||||
button.title = "Add Service Request";
|
||||
button.setAttribute("aria-label", `Add Service Request for ${number}`);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
|
||||
return button;
|
||||
}
|
||||
function open(number, trigger) {
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal");
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header");
|
||||
const heading = node("div", "opportunities-extension-action-form-heading");
|
||||
const title = node("h2", "", "Add Service Request");
|
||||
title.id = "opportunities-extension-sr-form-title";
|
||||
heading.append(title, node("p", "", String(number)));
|
||||
const opportunity = getOpportunity(number);
|
||||
const details = node("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(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
|
||||
}
|
||||
heading.append(details);
|
||||
const dismiss = node("button", "opportunities-extension-icon-button");
|
||||
dismiss.type = "button";
|
||||
dismiss.setAttribute("aria-label", "Close Service Request form");
|
||||
dismiss.append(createCloseIcon());
|
||||
dismiss.addEventListener("click", () => close());
|
||||
header.append(heading, dismiss);
|
||||
const form = node("form", "opportunities-extension-action-form");
|
||||
form.addEventListener("submit", event => event.preventDefault());
|
||||
const field = node("label", "opportunities-extension-action-form-field");
|
||||
const select = node("select");
|
||||
select.id = "opportunities-extension-sr-primary-pillar";
|
||||
select.name = "primaryPillar";
|
||||
field.htmlFor = select.id;
|
||||
field.append(node("span", "", "Primary pillar"), select);
|
||||
const autocomplete = searchableSelect(select, field);
|
||||
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
|
||||
status.setAttribute("role", "status");
|
||||
const footer = node("footer", "opportunities-extension-action-form-footer");
|
||||
const retry = node("button", "", "Retry");
|
||||
retry.type = "button";
|
||||
const cancel = node("button", "", "Cancel");
|
||||
cancel.type = "button";
|
||||
cancel.addEventListener("click", () => close());
|
||||
footer.append(retry, cancel);
|
||||
form.append(field);
|
||||
const extraControls = [];
|
||||
const extraLoads = [];
|
||||
function textField(name, label, type) {
|
||||
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
|
||||
const input = node(type === "textarea" ? "textarea" : "input");
|
||||
if (type === "textarea") input.rows = 4;
|
||||
else input.type = type;
|
||||
input.id = `opportunities-extension-sr-${name}`;
|
||||
input.name = name;
|
||||
wrapper.htmlFor = input.id;
|
||||
wrapper.append(node("span", "", label), input);
|
||||
form.append(wrapper);
|
||||
extraControls.push(input);
|
||||
}
|
||||
textField("customerDeadline", "Customer deadline", "date");
|
||||
function lookupField(name, label, request, radios = false) {
|
||||
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
|
||||
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
|
||||
const message = node("p", "opportunities-extension-action-form-status");
|
||||
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
|
||||
retryOption.type = "button";
|
||||
retryOption.setAttribute("aria-label", `Retry ${label}`);
|
||||
retryOption.hidden = true;
|
||||
extraControls.push(retryOption);
|
||||
let input, combo;
|
||||
if (radios) wrapper.append(node("legend", "", label));
|
||||
else {
|
||||
const field = node("label", "opportunities-extension-action-form-field");
|
||||
input = node("select");
|
||||
input.id = `opportunities-extension-sr-${name}`;
|
||||
input.name = name;
|
||||
field.htmlFor = input.id;
|
||||
field.append(node("span", "", label), input);
|
||||
combo = searchableSelect(input, field);
|
||||
content.append(field);
|
||||
extraControls.push(combo.input);
|
||||
}
|
||||
wrapper.append(content, message, retryOption);
|
||||
form.append(wrapper);
|
||||
let loading = false;
|
||||
async function loadOptions() {
|
||||
if (loading || !current()) return;
|
||||
loading = true;
|
||||
retryOption.hidden = true;
|
||||
message.hidden = false;
|
||||
message.setAttribute("role", "status");
|
||||
message.textContent = `Loading ${label.toLowerCase()}...`;
|
||||
wrapper.setAttribute("aria-busy", "true");
|
||||
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
|
||||
try {
|
||||
const items = await request();
|
||||
if (!current()) return;
|
||||
if (input) {
|
||||
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
|
||||
blank.value = "";
|
||||
input.append(blank);
|
||||
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
|
||||
input.value = "";
|
||||
input.disabled = !items.length;
|
||||
} else {
|
||||
content.textContent = "";
|
||||
const seen = new Set();
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(String(item.Id))) return;
|
||||
seen.add(String(item.Id));
|
||||
const row = node("label", "opportunities-extension-sr-service-option");
|
||||
const radio = node("input");
|
||||
radio.type = "radio";
|
||||
radio.name = "service";
|
||||
radio.value = String(item.Id);
|
||||
radio.id = `opportunities-extension-sr-service-${index}`;
|
||||
const copy = node("span");
|
||||
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
|
||||
abstract.id = `${radio.id}-description`;
|
||||
radio.setAttribute("aria-describedby", abstract.id);
|
||||
row.htmlFor = radio.id;
|
||||
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
|
||||
row.append(radio, copy);
|
||||
content.append(row);
|
||||
extraControls.push(radio);
|
||||
});
|
||||
}
|
||||
message.textContent = items.length ? "" : "No options available.";
|
||||
message.hidden = !!items.length;
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
message.setAttribute("role", "alert");
|
||||
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
|
||||
retryOption.hidden = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
|
||||
}
|
||||
}
|
||||
retryOption.addEventListener("click", loadOptions);
|
||||
extraLoads.push(loadOptions);
|
||||
}
|
||||
lookupField("language", "Language of delivery", requestLanguage);
|
||||
lookupField("platform", "Platform", requestPlatform);
|
||||
textField("description", "Description", "textarea");
|
||||
lookupField("services", "Services", requestServices, true);
|
||||
form.append(status, footer);
|
||||
dialog.append(header, form);
|
||||
modal.append(dialog);
|
||||
host.append(modal);
|
||||
const view = { modal, trigger, loading: false };
|
||||
active = view;
|
||||
const current = () => active === view && allowsCrmActivities();
|
||||
function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
|
||||
async function load() {
|
||||
if (view.loading || !current()) return;
|
||||
view.loading = true;
|
||||
retry.hidden = true;
|
||||
select.disabled = true;
|
||||
select.textContent = "";
|
||||
option("", "Loading...");
|
||||
autocomplete.sync();
|
||||
form.setAttribute("data-state", "loading");
|
||||
form.setAttribute("aria-busy", "true");
|
||||
status.hidden = false;
|
||||
status.setAttribute("role", "status");
|
||||
status.textContent = "Loading primary pillars...";
|
||||
try {
|
||||
const items = await requestPillars();
|
||||
if (!current()) return;
|
||||
select.textContent = "";
|
||||
option("", items.length ? "Select primary pillar" : "No options available");
|
||||
items.forEach(item => option(item.code, item.name));
|
||||
select.value = "";
|
||||
select.disabled = !items.length;
|
||||
form.setAttribute("data-state", "ready");
|
||||
status.hidden = true;
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
form.setAttribute("data-state", "error");
|
||||
select.textContent = "";
|
||||
option("", "Unavailable");
|
||||
status.setAttribute("role", "alert");
|
||||
status.textContent = error.message || "Unable to load primary pillars.";
|
||||
retry.hidden = false;
|
||||
} finally {
|
||||
view.loading = false;
|
||||
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
|
||||
}
|
||||
}
|
||||
retry.addEventListener("click", load);
|
||||
modal.addEventListener("click", event => { if (event.target === modal) close(); });
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = [dismiss, autocomplete.input, ...extraControls, retry, cancel].filter(control => !control.disabled && !control.hidden);
|
||||
const index = controls.indexOf(document.activeElement);
|
||||
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
||||
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||
}
|
||||
});
|
||||
dismiss.focus();
|
||||
load();
|
||||
extraLoads.forEach(loadOptions => loadOptions());
|
||||
}
|
||||
return { createButton, close, open };
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
function createInternalSrSession({ query, environment }) {
|
||||
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
|
||||
const { document, setTimeout } = environment;
|
||||
let recovery = null;
|
||||
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
|
||||
const now = () => environment.performance?.now() ?? Date.now();
|
||||
function isUnauthorized(result) {
|
||||
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
|
||||
if (status != null) return Number(status) === 401;
|
||||
// Older background workers may return only the request error text.
|
||||
return /failed with status 401\.$/.test(result?.error || "");
|
||||
}
|
||||
async function warmSession(id) {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||
frame.title = "Internal SR session initialization";
|
||||
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
|
||||
frame.title = `${sessionLabel} session initialization`;
|
||||
frame.tabIndex = -1;
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
@@ -16,19 +22,19 @@ function createInternalSrSession({ query, environment }) {
|
||||
frame.addEventListener("error", () => { blocked = true; });
|
||||
frame.addEventListener("load", () => { loaded = true; });
|
||||
try {
|
||||
if (!document.body) throw new Error("The current page cannot initialize the Internal SR session.");
|
||||
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
|
||||
document.body.append(frame);
|
||||
const deadline = now() + 60000;
|
||||
for (let attempt = 0; now() < deadline; attempt += 1) {
|
||||
// Check immediately, then briefly poll faster before backing off.
|
||||
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
|
||||
if (now() >= deadline) break;
|
||||
if (blocked) throw new Error("The Internal SR session iframe could not be loaded. Sign in manually and try again.");
|
||||
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
|
||||
const result = await query(id);
|
||||
if (result?.ok) return { id, result };
|
||||
if (result?.httpStatus !== 401) throw new Error(result?.error || "Unable to load Internal SR details.");
|
||||
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
}
|
||||
throw new Error(`Internal SR session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
||||
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
|
||||
} finally {
|
||||
// Stop remaining page resources without stopping the caller's page.
|
||||
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
|
||||
@@ -38,7 +44,7 @@ function createInternalSrSession({ query, environment }) {
|
||||
}
|
||||
async function request(id) {
|
||||
let result = await query(id);
|
||||
if (!result?.ok && result?.httpStatus === 401) {
|
||||
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
|
||||
if (!recovery) {
|
||||
recovery = warmSession(id);
|
||||
const pending = recovery;
|
||||
@@ -47,7 +53,7 @@ function createInternalSrSession({ query, environment }) {
|
||||
const recovered = await recovery;
|
||||
result = recovered.id === id ? recovered.result : await query(id);
|
||||
}
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Internal SR details.");
|
||||
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
|
||||
return result.items;
|
||||
}
|
||||
return { request };
|
||||
|
||||
113
src/features/opportunities/searchable-select.js
Normal file
113
src/features/opportunities/searchable-select.js
Normal file
@@ -0,0 +1,113 @@
|
||||
function createSearchableSelect({ environment }) {
|
||||
const { document } = environment;
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function searchableSelect(select, field, onChange) {
|
||||
const box = element("div", "opportunities-extension-action-combobox");
|
||||
const input = element("input");
|
||||
input.type = "text";
|
||||
input.autocomplete = "off";
|
||||
input.required = !!select.required;
|
||||
input.setAttribute("aria-required", String(!!select.required));
|
||||
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
|
||||
input.id = `${select.id}-search`;
|
||||
field.htmlFor = input.id;
|
||||
const list = element("div", "opportunities-extension-action-options");
|
||||
list.id = `${select.id}-options`;
|
||||
list.setAttribute("role", "listbox");
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
input.setAttribute("aria-controls", list.id);
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
select.hidden = true;
|
||||
select.tabIndex = -1;
|
||||
box.append(input, list);
|
||||
field.append(box);
|
||||
let matches = [], index = -1;
|
||||
const options = () => Array.from(select.children).filter(item => item.value);
|
||||
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
function closeList() {
|
||||
list.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.setAttribute("aria-activedescendant", "");
|
||||
}
|
||||
function sync() {
|
||||
closeList();
|
||||
input.disabled = !!select.disabled;
|
||||
input.placeholder = select.children[0]?.textContent || "Select";
|
||||
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
||||
input.title = input.value;
|
||||
}
|
||||
function highlight() {
|
||||
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
||||
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
||||
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
||||
}
|
||||
function choose(i) {
|
||||
if (!matches[i]) return;
|
||||
const changed = select.value !== matches[i].value;
|
||||
select.value = matches[i].value;
|
||||
sync();
|
||||
if (changed) onChange?.();
|
||||
}
|
||||
function positionList() {
|
||||
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||
const anchor = input.getBoundingClientRect?.();
|
||||
if (!bounds || !anchor) return;
|
||||
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
|
||||
const above = Math.max(0, anchor.top - bounds.top - 8);
|
||||
const opensAbove = below < 180 && above > below;
|
||||
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
||||
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
|
||||
}
|
||||
function render(query = "") {
|
||||
if (input.disabled) return;
|
||||
matches = options().filter(item => normalize(`${item.textContent} ${item.value}`).includes(normalize(query)));
|
||||
index = -1;
|
||||
list.textContent = "";
|
||||
matches.forEach((item, i) => {
|
||||
const row = element("div", "opportunities-extension-action-option", item.textContent);
|
||||
row.id = `${list.id}-${i}`;
|
||||
row.setAttribute("role", "option");
|
||||
row.setAttribute("aria-selected", "false");
|
||||
row.addEventListener("mousedown", event => event.preventDefault());
|
||||
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
|
||||
list.append(row);
|
||||
});
|
||||
if (!matches.length) {
|
||||
const empty = element("div", "opportunities-extension-action-option", "No results");
|
||||
empty.setAttribute("role", "status");
|
||||
list.append(empty);
|
||||
}
|
||||
positionList();
|
||||
list.hidden = false;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-activedescendant", "");
|
||||
}
|
||||
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||
input.addEventListener("input", () => render(input.value));
|
||||
input.addEventListener("blur", sync);
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (list.hidden) render();
|
||||
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
|
||||
highlight();
|
||||
} else if (event.key === "Enter" && !list.hidden) {
|
||||
event.preventDefault();
|
||||
choose(index < 0 && matches.length === 1 ? 0 : index);
|
||||
} else if (event.key === "Escape" && !list.hidden) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sync();
|
||||
} else if (event.key === "Tab") sync();
|
||||
});
|
||||
sync();
|
||||
return { input, sync };
|
||||
}
|
||||
return searchableSelect;
|
||||
}
|
||||
21
src/features/opportunities/sr-session-preparation.js
Normal file
21
src/features/opportunities/sr-session-preparation.js
Normal file
@@ -0,0 +1,21 @@
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) {
|
||||
let pending = null;
|
||||
function ensure() {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
try {
|
||||
await ensureCurrentUser();
|
||||
if (!allowsCrmActivities()) return;
|
||||
reportProgress("running");
|
||||
const result = await sendRequest();
|
||||
appendLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
|
||||
reportProgress("complete");
|
||||
} catch {
|
||||
reportProgress("error");
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
}
|
||||
return { ensure };
|
||||
}
|
||||
@@ -309,6 +309,36 @@
|
||||
.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-sr-form-dialog { width: min(900px, 100%); }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
|
||||
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
|
||||
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
|
||||
@media (min-width: 1000px) {
|
||||
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
|
||||
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
|
||||
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
|
||||
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
|
||||
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
|
||||
}
|
||||
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
|
||||
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
|
||||
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
|
||||
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
|
||||
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
|
||||
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
|
||||
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
|
||||
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
|
||||
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
|
||||
.opportunities-extension-sr-lookup-retry[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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
|
||||
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, environment }) {
|
||||
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
function element(tag, className, text) {
|
||||
@@ -8,109 +8,6 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user