Table sorting preferences will be saved and reused based on the user’s selections

This commit is contained in:
2026-10-25 00:39:55 -03:00
parent 19edee4e11
commit 0f1f399d1a
40 changed files with 896 additions and 230 deletions

View File

@@ -4,12 +4,17 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
## Opportunity Views
**Visao geral** is the default table view. **Calendario** displays opportunities
**Overview** is the default table view. **Calendar** displays opportunities
by Close Date (`EffectiveDate`) with month and week views only. Both views share
Reference, search and all existing filters; switching views does not send requests.
Events use date-only values without time-zone shifts, stage colors, and a read-only
detail with an opportunity link and copy button. Invalid or missing dates appear
in a separate list. Calendar navigation does not change the selected Reference.
Table sorting remembers the last column and direction independently for Overview,
Installed Base, Service Requests, Rate Card, Consumption projection and Manage Time.
Preferences are restored when reopening each view and survive page reloads in the
same browser profile. Opportunity product tables retain their original order.
Each day shows a filtered opportunity count, at most two events and an overflow
link to the remaining events. The grid fits the available height and reduces
visible events to one or zero when needed to keep every week visible. Missing

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
module.exports = {
"spa-page": ["src/auth/spa-page.js"],
"content": [
"src/features/preferences/table-sorting.js",
"src/shared/time-dates.js",
"src/features/manage-time/view.js",
"src/shared/messages.js",

View File

@@ -29,6 +29,7 @@ function createContentApplication(overrides = {}) {
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const tableSorting = createTableSorting({ environment });
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
@@ -137,7 +138,7 @@ function createContentApplication(overrides = {}) {
environment
});
const svgPaths = createSvgPaths({ environment });
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, tableSorting, searchableSelect, dates: createTimeDates(),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
async requestDelete(id) {
await srSessionPreparation.ensure();
@@ -363,6 +364,7 @@ function createContentApplication(overrides = {}) {
closeSrPopover: () => crmSrPopover.close()
});
const opportunitiesTable = createOpportunitiesTable({
tableSorting,
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
@@ -607,6 +609,7 @@ function createContentApplication(overrides = {}) {
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
});
const rampProjectionView = createRampProjectionView({
tableSorting,
saveEstimates: product => workloadForecastUpdate.save(product),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
MODAL_ID: contracts.MODAL_ID,
@@ -630,6 +633,7 @@ function createContentApplication(overrides = {}) {
});
const customerTableSearch = createCustomerTableSearch({ environment });
const installBaseView = createInstallBaseView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -645,6 +649,7 @@ function createContentApplication(overrides = {}) {
environment
});
const serviceRequestsView = createServiceRequestsView({
tableSorting,
createSearch: (...args) => customerTableSearch.createSearch(...args),
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
@@ -690,6 +695,7 @@ function createContentApplication(overrides = {}) {
environment
});
const ratecardView = createRatecardView({
tableSorting,
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
columns: ratecardModel.columns,
formatValue: (...args) => ratecardModel.formatValue(...args),

View File

@@ -99,6 +99,7 @@ function createContentModal({
}
const overlay = document.createElement("div");
overlay.setAttribute("lang", "en");
debugState.requestLog = [];
authSessionState.tokenRelayRequest = null;
authSessionState.consumerTokenServiceRequest = null;
@@ -154,7 +155,7 @@ function createContentModal({
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar");
closeButton.setAttribute("aria-label", "Close");
closeButton.textContent = "X";
closeButton.addEventListener("click", closeOpportunitiesModal);
@@ -170,7 +171,7 @@ function createContentModal({
const preferencesButton = document.createElement("button");
preferencesButton.type = "button";
preferencesButton.className = "opportunities-extension-icon-button";
preferencesButton.setAttribute("aria-label", "Abrir preferências");
preferencesButton.setAttribute("aria-label", "Open preferences");
preferencesButton.title = "Preferences";
preferencesButton.append(createSettingsIcon());
preferencesButton.addEventListener("click", openPreferencesModal);

View File

@@ -218,7 +218,7 @@ function createDebugContentView({
const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
const method = document.createElement("span");
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
@@ -228,7 +228,7 @@ function createDebugContentView({
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
: entry.status === "pending" ? "In progress" : "N/A";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
@@ -349,7 +349,7 @@ function createDebugContentView({
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"

View File

@@ -78,3 +78,5 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}
.opportunities-extension-install-base-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-install-base-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,4 +1,4 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment;
let active = null;
const columns = [
@@ -33,7 +33,8 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
}
return String(value).trim();
}
function createTable(items, loading = false) {
const sortColumns = columns.map(([key, , type]) => ({ key, type: ["amount", "quantity"].includes(type) ? "number" : type }));
function createTable(items, loading = false, sorting = tableSorting.create("installed-base", sortColumns), onSort) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
@@ -43,13 +44,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr");
columns.forEach(([key, , type]) => {
const cell = element("td");
@@ -103,6 +105,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("installed-base", sortColumns);
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -115,7 +118,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
const controls = [close, search.input, content];
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
@@ -130,7 +133,10 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
: "No installed base items found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0;
}
return { render(items, error = "") {

View File

@@ -20,6 +20,9 @@
.oe-time-grid { overflow-x: auto; }
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
.oe-time-grid .opportunities-extension-sort-button { min-width: 0; min-height: 28px; padding: 4px 12px 4px 0; border: 0; border-radius: 0; gap: 0; }
.oe-time-grid .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.oe-time-grid .oe-time-day-label { margin-bottom: 0; }
.oe-time-grid th:first-child { width: 23%; }
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }

View File

@@ -1,4 +1,4 @@
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
function createManageTime({ MODAL_ID, environment, tableSorting, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
const { document } = environment;
let active = null;
let ready = false;
@@ -142,6 +142,16 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
});
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
const sorting = tableSorting.create("manage-time", [
{ key: "service", value: row => row.type === "Non Service" ? "Non Service" : `${entry.services.find(sr => sr.srNumber === row.srNumber)?.srTitle || ""} ${row.srNumber}` },
{ key: "activity" },
{ key: "taskType", value: row => entry.taskTypes.find(item => item.lookUpCode_c === row.taskType)?.lookUpMeaning_c || row.taskType },
{ key: "notes" },
...hourFields.map((key, weekday) => ({ key, type: "number", value: row => {
const day = entry.days.find(item => dates.fromApi(item.Date).getUTCDay() === weekday);
return day ? row.hours[day.Date] : null;
} }))
]);
function rowPayload(row) {
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
@@ -247,7 +257,8 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
const focusedId = document.activeElement?.id;
combos = [];
entry.tbody.replaceChildren();
entry.rows.forEach((row, index) => {
sorting.select(entry.rows).forEach(row => {
const index = entry.rows.indexOf(row);
const tr = node("tr");
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
tr.className = "oe-time-duplicate-row";
@@ -451,15 +462,19 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
entry.rows = entry.drafts.get(key);
const head = node("thead"), row = node("tr");
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
for (const [key, label] of [["service", "Service Request"], ["activity", "Activity"], ["taskType", "Task Type"]]) {
const th = node("th"); sorting.header(th, key, label, renderRows); row.append(th);
}
entry.dayTotals = [];
for (const day of items) {
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
const dayButton = sorting.header(th, hourFields[parsed.getUTCDay()], ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()], renderRows);
const dayLabel = dayButton.children[0]; dayLabel.className += " oe-time-day-label";
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
th.append(node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
}
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
const notesHeading = node("th"); sorting.header(notesHeading, "notes", "Notes", renderRows); row.append(notesHeading);
for (const label of ["Copy", ""]) row.append(node("th", "", label));
[...row.children].forEach(th => th.setAttribute("scope", "col"));
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();

View File

@@ -60,7 +60,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
const count = dayCounts.get(key) || 0;
badge.hidden = count === 0;
badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
badge.title = `${count} ${count === 1 ? "opportunity" : "opportunities"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
@@ -137,7 +137,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) {
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/A"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
}
const badges = node("div", "opportunities-calendar-detail-badges");
@@ -154,13 +154,13 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} ${events.size === 1 ? "opportunity" : "opportunities"}`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
@@ -173,14 +173,14 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
closeDetails(false);
const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Opportunity views");
const tabs = {};
const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) {
for (const [key, label, panel] of [["overview", "Overview", tableSurface], ["calendar", "Calendar", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
@@ -234,12 +234,12 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
locale: "en", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
buttonText: { today: "Today", month: "Month", week: "Week" },
buttonHints: { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkHint: count => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
moreLinkClick: showDayOpportunities,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
@@ -255,7 +255,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`);
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/A"} | ${formatRevenue(item)}`);
summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary);
return { domNodes: [card] };
@@ -280,7 +280,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`));
undated.append(node("summary", "", `No valid Close Date (${missing.length})`));
for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);

View File

@@ -72,15 +72,15 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
: "N/A";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/A"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
].join("\n"));

View File

@@ -81,7 +81,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
heading.className = "opportunities-extension-sr-number";
const number = item.SrNumber;
const link = document.createElement(number ? "a" : "span");
link.textContent = number ? String(number) : "N/D";
link.textContent = number ? String(number) : "N/A";
if (number) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
link.target = "_blank";
@@ -137,7 +137,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
const label = document.createElement("span");
label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/A";
if (record.SrNumber) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
link.target = "_blank";
@@ -149,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/A" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
child.append(row, body);
children.append(child);
}

View File

@@ -28,7 +28,7 @@ function createOpportunitiesDashboard({
totalValue,
"TOTAL",
items.length,
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
(amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);
@@ -81,7 +81,7 @@ function createOpportunitiesDashboard({
}
}
return (amount) => amount.toLocaleString(undefined, {
return (amount) => amount.toLocaleString("en-US", {
maximumFractionDigits: 0
});
}

View File

@@ -144,7 +144,7 @@ function createOpportunitiesFormatters({
}
if (!currency) {
return number.toLocaleString();
return number.toLocaleString("en-US");
}
try {
@@ -153,7 +153,7 @@ function createOpportunitiesFormatters({
currency
}).format(number);
} catch (error) {
return `${currency} ${number.toLocaleString()}`;
return `${currency} ${number.toLocaleString("en-US")}`;
}
}

View File

@@ -33,7 +33,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
const context = node("dl", "opportunities-extension-action-form-context");
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/D" : String(value)));
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/A" : String(value)));
}
heading.append(title, context);
header.append(heading);
@@ -60,7 +60,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
const titleInput = field("title", "Title", "text", true).input;
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
deadline.readOnly = true;
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/A";
field("internalDeadline", "Internal Deadline", "date");
const platform = field("platform", "Platform", "select", true);
field("description", "Description", "textarea", true);

View File

@@ -412,6 +412,14 @@
table-layout: fixed;
}
.opportunities-extension-table-skeleton-row { height: 76px; }
.opportunities-extension-table-skeleton-text { width: 85%; max-width: 100%; }
.opportunities-extension-table-skeleton-row:nth-of-type(even) .opportunities-extension-table-skeleton-text { width: 70%; }
.opportunities-extension-table-skeleton-badge { flex: 0 1 30px; width: 30px; min-width: 0; height: 16px; }
.opportunities-extension-table-skeleton-row > td[data-column="customer"] .opportunities-extension-table-skeleton-badge { flex-basis: 18px; width: 18px; }
.opportunities-extension-table-skeleton-row > td[data-column="status"] .opportunities-extension-table-skeleton-text,
.opportunities-extension-table-skeleton-row > td[data-column="stage"] .opportunities-extension-table-skeleton-text { height: 20px; max-width: 66px; border-radius: 8px; }
/* Fixed table layout shares leftover width only between the two auto columns. */
.opportunities-extension-table > thead > tr > th[data-column="name"] { width: auto; }
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }

View File

@@ -1,4 +1,5 @@
function createOpportunitiesTable({
tableSorting,
ACCOUNT_DETAIL_URL,
OPPORTUNITIES_COLUMNS,
OPPORTUNITIES_TABLE_ID,
@@ -41,8 +42,8 @@ function createOpportunitiesTable({
opportunitiesTableState.opportunitiesTableState = {
...opportunitiesTableState.opportunitiesTableState,
...nextState,
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
};
renderOpportunitiesTable();
renderCustomerFilter();
@@ -64,7 +65,7 @@ function createOpportunitiesTable({
const caption = document.createElement("caption");
caption.className = "opportunities-extension-visually-hidden";
caption.textContent = "Opportunities";
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
const tableHead = document.createElement("thead");
const headerRow = document.createElement("tr");
@@ -85,6 +86,7 @@ function createOpportunitiesTable({
const sortButton = document.createElement("button");
sortButton.type = "button";
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
sortButton.className = "opportunities-extension-sort-button";
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
@@ -106,7 +108,32 @@ function createOpportunitiesTable({
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
if (opportunitiesTableState.opportunitiesTableState.status === "loading") {
for (let index = 0; index < 8; index++) {
const row = document.createElement("tr");
row.className = "opportunities-extension-table-skeleton-row";
row.setAttribute("aria-hidden", "true");
OPPORTUNITIES_COLUMNS.forEach(column => {
const cell = document.createElement("td");
cell.setAttribute("data-column", column.key);
const bar = document.createElement("span");
bar.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-text";
cell.append(bar);
if (column.key === "name" || column.key === "customer") {
const actions = document.createElement("span");
actions.className = "opportunities-extension-cell-actions";
for (let action = 0; action < (column.key === "name" ? 4 : 3); action++) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-table-skeleton-badge";
actions.append(badge);
}
cell.append(actions);
}
row.append(cell);
});
tableBody.append(row);
}
} else if (opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.className = "opportunities-extension-table-status";
@@ -193,11 +220,13 @@ function createOpportunitiesTable({
}
function sortOpportunitiesBy(key) {
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
opportunitiesTableState.opportunitiesTableState.sortKey = key;
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
? "descending"
: "ascending";
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
renderOpportunitiesTable();
}

View File

@@ -155,9 +155,9 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
badge.setAttribute("data-state", state);
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
`Action: ${action.name ?? "N/A"}`, `ID: ${action.id ?? "N/A"}`, `Status: ${action.status ?? "N/A"}`,
`Owner: ${action.owner || "N/A"}`, `Email: ${action.ownerEmail || "N/A"}`, `Workload: ${action.workloadName || "N/A"}`,
`Description: ${action.workloadDescription || "N/A"}`, `Start: ${action.startDate || "N/A"}`, `End: ${action.endDate || "N/A"}`
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";

View File

@@ -0,0 +1,75 @@
function createTableSorting({ environment }) {
const memory = new Map();
const storageKey = id => `opportunities-extension-table-sort-v1-${id}`;
function read(id, columns) {
let saved = memory.get(id);
if (!saved) {
try { saved = JSON.parse(environment.localStorage?.getItem(storageKey(id)) || "null"); } catch { /* Storage is optional. */ }
}
return saved && columns.some(column => column.key === saved.key && column.sortable !== false)
&& ["ascending", "descending"].includes(saved.direction)
? { key: saved.key, direction: saved.direction } : { key: "", direction: "ascending" };
}
function save(id, state) {
memory.set(id, { ...state });
try { environment.localStorage?.setItem(storageKey(id), JSON.stringify(state)); } catch { /* Retain the page-session preference. */ }
}
function value(raw, type) {
if (raw == null || String(raw).trim() === "") return null;
if (type === "number") return Number.isFinite(Number(raw)) ? Number(raw) : null;
if (type === "date") {
const timestamp = Date.parse(raw);
return Number.isFinite(timestamp) ? timestamp : null;
}
return String(raw);
}
function create(id, columns) {
let state;
const headers = new Map();
const get = () => state || (state = read(id, columns));
function toggle(key) {
if (!columns.some(column => column.key === key && column.sortable !== false)) return;
const previous = get();
state = { key, direction: previous.key === key && previous.direction === "ascending" ? "descending" : "ascending" };
save(id, state);
headers.forEach(update);
}
function select(items) {
const selected = get(), column = columns.find(column => column.key === selected.key);
if (!column) return [...items];
const direction = selected.direction === "descending" ? -1 : 1;
return items.map((item, index) => ({ item, index, value: value(column.value ? column.value(item) : item[column.key], column.type) }))
.sort((a, b) => {
if (a.value === null || b.value === null) return a.value === b.value ? a.index - b.index : a.value === null ? 1 : -1;
const result = typeof a.value === "number" ? a.value - b.value : a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: "base" });
return result * direction || a.index - b.index;
}).map(record => record.item);
}
function update({ th, button, key, label }) {
const selected = get();
const direction = selected.key === key ? selected.direction : "none";
th.setAttribute("aria-sort", direction);
button.setAttribute("data-sort-direction", direction);
button.setAttribute("aria-label", `Sort by ${label}${direction === "none" ? "" : `, ${direction}`}`);
}
function header(th, key, label, onChange, disabled = false) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-sort-button";
button.disabled = disabled;
button.title = `Sort by ${label}`;
button.setAttribute("data-sort-key", key);
const text = environment.document.createElement("span");
text.className = "opportunities-extension-sort-label";
text.textContent = label;
button.append(text);
const record = { th, button, key, label };
headers.set(key, record); update(record);
th.append(button);
button.addEventListener("click", () => { toggle(key); onChange?.(key); });
return button;
}
return { get, toggle, select, header };
}
return { read, save, create };
}

View File

@@ -60,7 +60,7 @@ function createPreferencesView({
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button";
closeButton.setAttribute("aria-label", "Fechar preferências");
closeButton.setAttribute("aria-label", "Close preferences");
closeButton.title = "Close";
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closePreferencesModal);

View File

@@ -189,7 +189,7 @@ function createProductsContentJobs({
timeStyle: "short"
}).format(new Date(timestamp));
} catch (error) {
return new Date(timestamp).toLocaleString();
return new Date(timestamp).toLocaleString("en-GB");
}
}

View File

@@ -13,12 +13,12 @@ function createProductsDetails({
function createProductsRampBadge(productsResult) {
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
.map(item => item && item.RampMonths_c)
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
if (!values.length) return null;
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.setAttribute("data-missing", String(values.includes("N/A")));
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
@@ -114,7 +114,7 @@ function createProductsDetails({
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
{ label: "Ramp Months", value: (item) => item.RampMonths_c === null || item.RampMonths_c === undefined || String(item.RampMonths_c).trim() === ""
? "N/D" : formatProductNumber(item.RampMonths_c) },
? "N/A" : formatProductNumber(item.RampMonths_c) },
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
];

View File

@@ -1,4 +1,4 @@
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment }) {
function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCloseIcon, expandProducts, saveEstimates, notify, formatAmount, formatDate, environment, tableSorting }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
@@ -96,8 +96,15 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
const table = node("table", "opportunities-extension-projection-table");
const caption = node("caption", "opportunities-extension-visually-hidden", `Monthly consumption comparison (${currency}). Difference is forecast minus calculated.`);
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
const sorting = tableSorting.create("consumption-projection", [
{ key: "month", type: "number", value: item => item.year * 12 + item.month },
...["actual", "calculated", "difference"].map(key => ({ key, type: "number" }))
]);
const rowNodes = new Map();
const renderRows = () => tbody.replaceChildren(...sorting.select(result.rows).map(item => rowNodes.get(item)));
for (const [key, label] of [["month", "Month"], ["actual", "Forecast"], ["calculated", "Calculated"], ["difference", "Difference"]]) {
const th = node("th"); th.scope = "col";
sorting.header(th, key, label, renderRows); headRow.append(th);
}
head.append(headRow);
for (const item of result.rows) {
@@ -108,8 +115,9 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
tr.append(month);
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
tbody.append(tr);
rowNodes.set(item, tr);
}
renderRows();
table.append(caption, head, tbody);
viewport.append(table);
body.append(viewport);
@@ -203,7 +211,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const controls = [dismiss, viewport, updateButton].filter(control => !control.disabled);
const controls = [dismiss, viewport, ...table.querySelectorAll("button"), updateButton].filter(control => !control.disabled);
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();

View File

@@ -18,6 +18,9 @@
.opportunities-extension-projection-table th, .opportunities-extension-projection-table td { padding: 9px 12px; border-bottom: 1px solid #dedbd7; text-align: right; white-space: nowrap; }
.opportunities-extension-projection-table th:first-child { text-align: left; }
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
.opportunities-extension-projection-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 18px 4px 0; justify-content: flex-end; }
.opportunities-extension-projection-table th:first-child .opportunities-extension-sort-button { justify-content: flex-start; }
.opportunities-extension-projection-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }
.opportunities-extension-projection-table tbody th { font-weight: 400; }
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }

View File

@@ -1,4 +1,4 @@
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, environment }) {
function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, selectItems, createConsumptionHeader, createPlanDetails, tableSorting, environment }) {
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
let active = null;
let closing = null;
@@ -78,6 +78,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
const headRow = element("tr");
const sortHeaders = [];
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
const savedSort = tableSorting.read("rate-card", columns);
current.sortKey = savedSort.key;
current.direction = savedSort.direction === "descending" ? "desc" : "asc";
active = current;
background.inert = true;
const alive = () => active === current && root.isConnected && !parent.hidden;
@@ -98,6 +101,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
sort.addEventListener("click", () => {
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
current.sortKey = column.key;
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
updateSortHeaders();
if (current.status === "ready") renderRows();
});
@@ -106,6 +110,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
headRow.append(th);
});
head.append(headRow);
updateSortHeaders();
const body = element("tbody");
table.append(caption, colgroup, head, body);
viewport.append(table);

View File

@@ -38,3 +38,5 @@
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}
.opportunities-extension-service-requests-table .opportunities-extension-sort-button { min-height: 28px; padding: 4px 16px 4px 0; overflow-wrap: anywhere; }
.opportunities-extension-service-requests-table .opportunities-extension-sort-button::after { right: 0; font-size: 12px; }

View File

@@ -1,4 +1,4 @@
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
const { document } = environment;
let active = null;
const columns = [
@@ -31,7 +31,8 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const text = value === null || value === undefined || value === "" ? "-" : String(value);
return text !== "-" && (key === "created" || key === "lastUpdated") ? formatOracleResponseDate(value) : text;
}
function createTable(items, loading = false) {
const sortColumns = columns.map(([key, , value]) => ({ key, value, type: ["created", "lastUpdated"].includes(key) ? "date" : "text" }));
function createTable(items, loading = false, sorting = tableSorting.create("service-requests", sortColumns), onSort) {
const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("aria-label", "Customer service requests");
const group = element("colgroup");
@@ -39,13 +40,14 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const header = element("tr");
columns.forEach(([key, label]) => {
group.append(element("col", `service-requests-col-${key}`));
const th = element("th", "", label);
const th = element("th");
th.scope = "col";
sorting.header(th, key, label, onSort, loading);
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
(loading ? Array.from({ length: 8 }, () => ({})) : sorting.select(items)).forEach(item => {
const row = element("tr");
columns.forEach(([key, , getValue]) => {
const cell = element("td");
@@ -99,6 +101,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
status.setAttribute("role", "status");
let loadedItems = [], loaded = false, failure = "";
const sorting = tableSorting.create("service-requests", sortColumns);
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
@@ -111,7 +114,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
const controls = [close, search.input, content];
const controls = [close, search.input, content, ...content.querySelectorAll("button")].filter(control => !control.disabled);
const index = controls.indexOf(document.activeElement);
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
}
@@ -126,7 +129,10 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
: "No open service requests found for this customer.");
status.setAttribute("role", failure ? "alert" : "status");
content.append(status);
if (!failure && items.length) content.append(createTable(items));
if (!failure && items.length) content.append(createTable(items, false, sorting, key => {
renderResults();
content.querySelector(`[data-sort-key="${key}"]`)?.focus();
}));
content.scrollTop = 0;
}
return { render(items, error = "") {

View File

@@ -59,7 +59,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
checkIcon(ramp, "ramp", "1 | 2 | 3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
checkIcon(missingRamp, "ramp", "N/D");
checkIcon(missingRamp, "ramp", "N/A");
assert.equal(missingRamp.attributes["data-missing"], "true");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [

View File

@@ -16,7 +16,7 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
@@ -29,7 +29,7 @@ test("SR badge shows reported hours, sums linked SRs and preserves missing value
});
test("SR tooltip formats customer deadlines without timezone day shifts", async () => {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/D"], [undefined, "N/D"], ["", "N/D"]]) {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/A"], [undefined, "N/A"], ["", "N/A"]]) {
const app = setup(async () => response({ items: [{ ...sr("A"), CustomerDeadline_c: value }], hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));

View File

@@ -44,7 +44,7 @@ test("request summaries show duration after status, including zero, failure and
const h = fixture();
h.add("duration", "requestOpportunities", 1);
const entry = h.app.debugState.requestLog.at(-1);
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/A"]]) {
Object.assign(entry, { durationMs, status });
h.app.debugContentView.renderDebugPanel();
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");

View File

@@ -92,7 +92,9 @@ class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
querySelector(selector) { return walk(this).find(node => node.attributes["data-sort-key"] === selector.match(/data-sort-key="([^"]+)"/)?.[1]); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
@@ -100,13 +102,28 @@ class Element {
get isConnected() { return Boolean(this.parent); }
click() { return this.listeners.click(); }
}
function setup() {
function setup(localStorage) {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
return { app, overlay, document };
}
test("installBase sorts typed values and restores the selected header across application instances", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [{ productName: "Ten", quantity: 10 }, { productName: "Two", quantity: 2 }] ;
const first = setup(storage);
first.app.installBaseView.openInstallBaseModal(customer).render(items);
walk(first.overlay).find(node => node.attributes["data-sort-key"] === "quantity").click();
const second = setup(storage);
second.app.installBaseView.openInstallBaseModal(customer).render(items);
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
assert.equal(rows[0].children[7].textContent, "2");
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "quantity").parent.attributes["aria-sort"], "ascending");
assert.equal(items[0].quantity, 10);
});
const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
const walk = node => [node, ...node.children.flatMap(walk)];

View File

@@ -56,6 +56,7 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
const calls = [], notifications = [];
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete,
tableSorting: factory("src/features/preferences/table-sorting.js", "createTableSorting")({ environment: { document, localStorage } }),
notify: (message, isError = false) => notifications.push({ message, isError }),
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
app.setReady(true);
@@ -64,6 +65,27 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
return { app, calls, nodes, find, host, notifications };
}
test("sorting Manage Time preserves notes, hours and deletion IDs and restores on reopening", async () => {
const deleted = [], saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const records = [
{ id: 7, srNumber: "SR002", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, notes: "Second", tag: "SERVICE" },
{ id: 8, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 3, notes: "First", tag: "SERVICE" }
];
const h = setup(undefined, undefined, undefined, async () => records, storage, undefined, async id => deleted.push(id));
h.app.open(); h.calls[0].resolve(week()); await flush();
h.find("Sort by Service Request").click();
const selects = () => h.nodes().filter(node => node.tag === "select" && node.attributes["aria-label"]?.startsWith("Service Request,"));
assert.equal(selects()[0].value, "SR001");
assert.equal(h.nodes().find(node => node.tag === "textarea" && node.attributes["aria-label"] === "Notes, row 2").value, "First");
assert.equal(h.find("Hours 12-OCT-2026, row 2").value, "3");
h.app.close(); h.app.open(); h.calls[1].resolve(week()); await flush();
assert.equal(selects()[0].value, "SR001");
h.find("Delete row 2").click(); h.find("Save").click(); await flush();
assert.deepEqual(deleted, ["8"]);
assert.equal(selects()[0].value, "SR002");
});
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
const deleted = [];
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id));

View File

@@ -53,6 +53,18 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
return h;
}
test("calendar controls, accessibility labels and missing dates are English", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
const { options } = h.activate();
assert.equal(options.locale, "en");
assert.deepEqual(plain(options.buttonText), { today: "Today", month: "Month", week: "Week" });
assert.deepEqual(plain(options.buttonHints), { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" });
assert.equal(options.moreLinkHint(1), "View 1 more opportunity");
assert.equal(options.moreLinkHint(3), "View 3 more opportunities");
assert.ok(h.nodes().some(node => node.attributes["aria-label"] === "Opportunity views"));
assert.ok(h.nodes().some(node => node.textContent === "No valid Close Date (1)"));
});
test("close dates retain the API day across offsets and reject invalid dates", () => {
const { api } = setup();
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
@@ -81,10 +93,10 @@ test("daily count badges follow filters and are cleaned up when cells unmount",
const badge = frame.children[0];
assert.equal(badge.textContent, "3");
assert.equal(badge.hidden, false);
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
assert.equal(badge.attributes["aria-label"], "3 opportunities");
h.api.render([h.state.items[0]], h.state);
assert.equal(badge.textContent, "1");
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
assert.equal(badge.attributes["aria-label"], "1 opportunity");
h.api.render([], h.state);
assert.equal(badge.textContent, "0");
assert.equal(badge.attributes["data-empty"], "true");
@@ -120,7 +132,7 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Overview", "Calendar"]);
assert.equal(tabs[0].attributes["aria-selected"], "true");
tabs[1].click();
const instance = h.instances[0];
@@ -171,7 +183,7 @@ test("undated opportunities remain visible and open read-only details", () => {
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/A"));
assert.equal(h.api.closeDetails(), true);
assert.equal(h.document.activeElement, undated.children[1]);
});
@@ -216,7 +228,7 @@ test("overflow uses our dialog instead of the FullCalendar offsetParent-dependen
test("calendar events show their stage and handle missing stage values", () => {
const h = setup();
const calendar = h.activate();
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/A"]]) {
const item = { ...h.state.items[0], ForecastGroup_c: value };
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
assert.equal(domNodes[0].children.length, 2);

View File

@@ -31,11 +31,11 @@ const synthetic = (number = "TEST-1", name = "Synthetic opportunity") => ({
EffectiveDate: "2026-10-23", LastUpdateDate: "2026-09-22T13:15:00Z", StatusCode: "OPEN", ForecastGroup_c: "FORECAST"
});
function setup(items = [synthetic()]) {
function setup(items = [synthetic()], localStorage) {
const table = new Element("table");
const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
app.currentUser.allowsCrmActivities = () => true;
const summaries = [];
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
@@ -46,6 +46,22 @@ function setup(items = [synthetic()]) {
return { app, table, rows, cell, headers, summaries };
}
test("Overview restores independent column and direction preferences across reloads and new data", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [synthetic("B", "Beta"), synthetic("A", "Alpha")];
const first = setup(items, storage);
first.app.opportunitiesTable.sortOpportunitiesBy("name");
first.app.opportunitiesTable.sortOpportunitiesBy("name");
const second = setup(items.slice().reverse(), storage);
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortKey, "name");
assert.equal(second.app.opportunitiesTableState.opportunitiesTableState.sortDirection, "descending");
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading" });
second.app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
assert.equal(second.cell("name").children[0].children[0].textContent, "Beta");
});
test("ten columns retain their links and put product/customer controls below the corresponding text", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ ProdGroupName: "Cloud" }] });
@@ -83,8 +99,8 @@ test("ramp months preserve zero and flag missing values beside the product count
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/D | 6 | 3"]
[[], null], [[null], "N/A"], [[null, undefined, "", " "], "N/A"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -93,7 +109,7 @@ test("ramp months preserve zero and flag missing values beside the product count
if (expected === null) assert.equal(badge(), undefined);
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/A")));
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
@@ -200,7 +216,7 @@ test("product table distinguishes unavailable ramp months from numeric zero", ()
findClass(h.cell("name"), "opportunities-extension-products-count").click();
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
const rows = table.children.find(child => child.tag === "tbody").children;
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/D", "N/D", "N/D", "N/D", "0", "0", "6"]);
assert.deepEqual(rows.map(row => row.children[9].textContent), ["N/A", "N/A", "N/A", "N/A", "0", "0", "6"]);
});
test("customer consumption still opens immediately, uses the right customer and preserves loading state", async () => {
@@ -239,9 +255,31 @@ test("pending, zero-count, failed and missing-data rows preserve their layout an
assert.equal(h.cell("customer").children[0].children[0].textContent, "-");
});
test("loading, empty and error messages span exactly the visible columns", () => {
test("loading skeleton preserves columns and action slots, then disappears on success or failure", () => {
const h = setup();
for (const status of ["loading", "empty", "error"]) {
for (const nextStatus of ["ready", "empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status: "loading", items: [], message: "Loading opportunities..." });
assert.equal(h.table.attributes["aria-busy"], "true");
assert.equal(h.rows().length, 8);
assert.equal(h.headers().length, 10);
assert.ok(h.headers().every(header => header.children[0].disabled));
for (const row of h.rows()) {
assert.equal(row.attributes["aria-hidden"], "true");
assert.deepEqual(row.children.map(cell => cell.attributes["data-column"]), h.headers().map(header => header.attributes["data-column"]));
assert.ok(row.children.every(cell => findClass(cell, "opportunities-extension-account-plans-skeleton-bar")));
assert.equal(walk(row).filter(node => hasClass(node, "opportunities-extension-table-skeleton-badge")).length, 7);
assert.equal(walk(row).some(node => node.tag === "button" || node.tag === "a"), false);
}
h.app.opportunitiesTable.setOpportunitiesTableState({ status: nextStatus, items: nextStatus === "ready" ? [synthetic()] : [], message: "" });
assert.equal(h.table.attributes["aria-busy"], "false");
assert.equal(findClass(h.table, "opportunities-extension-table-skeleton-row"), undefined);
assert.ok(h.headers().every(header => !header.children[0].disabled));
}
});
test("empty and error messages span exactly the visible columns", () => {
const h = setup();
for (const status of ["empty", "error"]) {
h.app.opportunitiesTable.setOpportunitiesTableState({ status, message: "Synthetic state" });
assert.equal(h.rows().length, 1);
assert.equal(h.rows()[0].children[0].colSpan, 10);

View File

@@ -90,6 +90,8 @@ function dom() {
set textContent(value) { this.text = String(value); this.children = []; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
@@ -172,6 +174,26 @@ test("single-product opportunity alert opens the comparison directly with opport
assert.equal(host.children.length, 1);
});
test("projection sorting restores on reopening without changing forecast source order", () => {
const { document, host } = dom();
const app = loadApplication("content", { document, Intl, Date });
const input = product({ forecast: [
{ year: 2026, month: 11, adjustedConsumptionAmount: 10 },
{ year: 2026, month: 12, adjustedConsumptionAmount: 20 }
] });
const before = JSON.stringify(input);
const opportunity = { OptyNumber: "TEST" };
app.rampProjectionView.open(input, opportunity);
const button = walk(host).find(node => node.attributes["data-sort-key"] === "month");
button.click(); button.click();
const firstMonth = () => walk(host).find(node => node.tag === "tbody").children[0].children[0].textContent;
const expected = firstMonth();
app.rampProjectionView.close(); app.rampProjectionView.open(input, opportunity);
assert.equal(firstMonth(), expected);
assert.equal(walk(host).find(node => node.attributes["data-sort-key"] === "month").parent.attributes["aria-sort"], "descending");
assert.equal(JSON.stringify(input), before);
});
test("product column opens accessible read-only comparison and restores focus/background on close", () => {
const { document, host, content } = dom();
const app = loadApplication("content", { document, Intl, Date });

View File

@@ -156,6 +156,19 @@ function setup() {
}
const tick = () => new Promise(resolve => setImmediate(resolve));
test("Rate Card restores the last column and direction on reopening", async () => {
const { app, buttons, find, requests } = setup();
const opening = buttons[0].listeners.click(); await tick();
requests[0].resolve({ ok: true, items: [item], debugRequests: [] }); await opening;
find("table").querySelectorAll("th")[9].children[0].listeners.click();
find("table").querySelectorAll("th")[9].children[0].listeners.click();
app.ratecardView.closeRatecardDrawer({ immediate: true });
const reopened = buttons[0].listeners.click(); await tick();
if (requests[1]) requests[1].resolve({ ok: true, items: [item], debugRequests: [] });
await reopened;
assert.equal(find("table").querySelectorAll("th")[9].attributes["aria-sort"], "descending");
});
test("Rate card sits beside Contract ID, opens a skeleton, searches and sorts without HTML injection", async () => {
const { app, root, buttons, find, document, requests, logs } = setup();
assert.equal(buttons.length, 2);

View File

@@ -125,7 +125,9 @@ class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
querySelectorAll(selector) { return walk(this).filter(node => node !== this && node.tag === selector); }
querySelector(selector) { return walk(this).find(node => node.attributes["data-sort-key"] === selector.match(/data-sort-key="([^"]+)"/)?.[1]); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
@@ -134,13 +136,28 @@ class Element {
click() { return this.listeners.click(); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
function setup() {
function setup(localStorage) {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
const app = loadApplication("content", { document, Intl, Date, localStorage });
return { app, overlay, document };
}
test("serviceRequests sorts typed values and restores the selected header across application instances", () => {
const saved = new Map();
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
const items = [{ id: "Later", created: "2026-02-01" }, { id: "Earlier", created: "2026-01-31" }] ;
const first = setup(storage);
first.app.serviceRequestsView.openServiceRequestsModal(customer).render(items);
walk(first.overlay).find(node => node.attributes["data-sort-key"] === "created").click();
const second = setup(storage);
second.app.serviceRequestsView.openServiceRequestsModal(customer).render(items);
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
assert.equal(rows[0].children[2].textContent, "Earlier");
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "created").parent.attributes["aria-sort"], "ascending");
assert.equal(items[0].id, "Later");
});
const customer = { CustomerAccount: { PartyNumber: "customer-test", PartyUniqueName: "Synthetic customer" } };
test("service request search covers nested and formatted columns and preserves errors and full results on clearing", () => {

View File

@@ -0,0 +1,44 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const factory = new vm.Script(fs.readFileSync("src/features/preferences/table-sorting.js", "utf8") + "\ncreateTableSorting;").runInNewContext();
const plain = value => JSON.parse(JSON.stringify(value));
const columns = [{ key: "number", type: "number" }, { key: "date", type: "date" }, { key: "name" }];
test("table preferences survive recreation, remain independent and reject obsolete or malformed settings", () => {
const saved = new Map();
const environment = { localStorage: { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) } };
const helper = factory({ environment });
const a = helper.create("a", columns);
a.toggle("number"); a.toggle("number");
const restored = factory({ environment });
assert.deepEqual(plain(restored.read("a", columns)), { key: "number", direction: "descending" });
assert.equal(restored.read("b", columns).key, "");
for (const invalid of ["{", 'null', '{"key":"removed","direction":"ascending"}', '{"key":"number","direction":"wrong"}']) {
saved.set("opportunities-extension-table-sort-v1-b", invalid);
assert.equal(restored.read("b", columns).key, "");
}
a.toggle("unknown");
assert.equal(a.get().key, "number");
});
test("sorting is typed, stable, immutable and keeps missing values last in both directions", () => {
const sorting = factory({ environment: {} }).create("table", columns);
const rows = [{ number: "10", date: "2026-02-01", name: "Item 10" }, { number: 2, date: "2026-01-31", name: "Item 2" }, { number: null }, { number: 2 }];
sorting.toggle("number");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[3], rows[0], rows[2]]);
sorting.toggle("number");
assert.deepEqual([...sorting.select(rows)], [rows[0], rows[1], rows[3], rows[2]]);
sorting.toggle("date");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[0], rows[2], rows[3]]);
sorting.toggle("name");
assert.deepEqual([...sorting.select(rows)], [rows[1], rows[0], rows[2], rows[3]]);
assert.equal(rows[0].number, "10");
});
test("blocked storage retains a usable in-memory preference", () => {
const helper = factory({ environment: { get localStorage() { throw new Error("Blocked"); } } });
helper.create("a", columns).toggle("name");
assert.equal(helper.create("a", columns).get().key, "name");
});