Table sorting preferences will be saved and reused based on the user’s selections
This commit is contained in:
@@ -4,12 +4,17 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
|
|||||||
|
|
||||||
## Opportunity Views
|
## 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
|
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.
|
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
|
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
|
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.
|
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
|
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
|
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
|
visible events to one or zero when needed to keep every week visible. Missing
|
||||||
|
|||||||
291
dist/chromium/content.js
vendored
291
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
291
dist/firefox/content.js
vendored
291
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
"spa-page": ["src/auth/spa-page.js"],
|
"spa-page": ["src/auth/spa-page.js"],
|
||||||
"content": [
|
"content": [
|
||||||
|
"src/features/preferences/table-sorting.js",
|
||||||
"src/shared/time-dates.js",
|
"src/shared/time-dates.js",
|
||||||
"src/features/manage-time/view.js",
|
"src/features/manage-time/view.js",
|
||||||
"src/shared/messages.js",
|
"src/shared/messages.js",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
const authSessionState = createAuthSessionState(contracts);
|
const authSessionState = createAuthSessionState(contracts);
|
||||||
const authSalesCommonState = createAuthSalesCommonState();
|
const authSalesCommonState = createAuthSalesCommonState();
|
||||||
const opportunitiesQueryState = createOpportunitiesQueryState();
|
const opportunitiesQueryState = createOpportunitiesQueryState();
|
||||||
|
const tableSorting = createTableSorting({ environment });
|
||||||
const opportunitiesTableState = createOpportunitiesTableState();
|
const opportunitiesTableState = createOpportunitiesTableState();
|
||||||
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
|
||||||
const preferencesState = createPreferencesState(contracts);
|
const preferencesState = createPreferencesState(contracts);
|
||||||
@@ -137,7 +138,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const svgPaths = createSvgPaths({ 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),
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
async requestDelete(id) {
|
async requestDelete(id) {
|
||||||
await srSessionPreparation.ensure();
|
await srSessionPreparation.ensure();
|
||||||
@@ -363,6 +364,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
closeSrPopover: () => crmSrPopover.close()
|
closeSrPopover: () => crmSrPopover.close()
|
||||||
});
|
});
|
||||||
const opportunitiesTable = createOpportunitiesTable({
|
const opportunitiesTable = createOpportunitiesTable({
|
||||||
|
tableSorting,
|
||||||
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
renderCalendar: (...args) => opportunityCalendar.render(...args),
|
||||||
renderActivityFilters: () => activityFilters.render(),
|
renderActivityFilters: () => activityFilters.render(),
|
||||||
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
|
||||||
@@ -607,6 +609,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
queueProductsTableRender: () => productsContentJobs.queueProductsTableRender()
|
||||||
});
|
});
|
||||||
const rampProjectionView = createRampProjectionView({
|
const rampProjectionView = createRampProjectionView({
|
||||||
|
tableSorting,
|
||||||
saveEstimates: product => workloadForecastUpdate.save(product),
|
saveEstimates: product => workloadForecastUpdate.save(product),
|
||||||
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
@@ -630,6 +633,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const customerTableSearch = createCustomerTableSearch({ environment });
|
const customerTableSearch = createCustomerTableSearch({ environment });
|
||||||
const installBaseView = createInstallBaseView({
|
const installBaseView = createInstallBaseView({
|
||||||
|
tableSorting,
|
||||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
@@ -645,6 +649,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const serviceRequestsView = createServiceRequestsView({
|
const serviceRequestsView = createServiceRequestsView({
|
||||||
|
tableSorting,
|
||||||
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
createSearch: (...args) => customerTableSearch.createSearch(...args),
|
||||||
MODAL_ID: contracts.MODAL_ID,
|
MODAL_ID: contracts.MODAL_ID,
|
||||||
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
|
||||||
@@ -690,6 +695,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const ratecardView = createRatecardView({
|
const ratecardView = createRatecardView({
|
||||||
|
tableSorting,
|
||||||
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
|
||||||
columns: ratecardModel.columns,
|
columns: ratecardModel.columns,
|
||||||
formatValue: (...args) => ratecardModel.formatValue(...args),
|
formatValue: (...args) => ratecardModel.formatValue(...args),
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function createContentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
|
overlay.setAttribute("lang", "en");
|
||||||
debugState.requestLog = [];
|
debugState.requestLog = [];
|
||||||
authSessionState.tokenRelayRequest = null;
|
authSessionState.tokenRelayRequest = null;
|
||||||
authSessionState.consumerTokenServiceRequest = null;
|
authSessionState.consumerTokenServiceRequest = null;
|
||||||
@@ -154,7 +155,7 @@ function createContentModal({
|
|||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
closeButton.type = "button";
|
closeButton.type = "button";
|
||||||
closeButton.className = "opportunities-extension-icon-button";
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
closeButton.setAttribute("aria-label", "Fechar");
|
closeButton.setAttribute("aria-label", "Close");
|
||||||
closeButton.textContent = "X";
|
closeButton.textContent = "X";
|
||||||
closeButton.addEventListener("click", closeOpportunitiesModal);
|
closeButton.addEventListener("click", closeOpportunitiesModal);
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ function createContentModal({
|
|||||||
const preferencesButton = document.createElement("button");
|
const preferencesButton = document.createElement("button");
|
||||||
preferencesButton.type = "button";
|
preferencesButton.type = "button";
|
||||||
preferencesButton.className = "opportunities-extension-icon-button";
|
preferencesButton.className = "opportunities-extension-icon-button";
|
||||||
preferencesButton.setAttribute("aria-label", "Abrir preferências");
|
preferencesButton.setAttribute("aria-label", "Open preferences");
|
||||||
preferencesButton.title = "Preferences";
|
preferencesButton.title = "Preferences";
|
||||||
preferencesButton.append(createSettingsIcon());
|
preferencesButton.append(createSettingsIcon());
|
||||||
preferencesButton.addEventListener("click", openPreferencesModal);
|
preferencesButton.addEventListener("click", openPreferencesModal);
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function createDebugContentView({
|
|||||||
const startedTime = document.createElement("time");
|
const startedTime = document.createElement("time");
|
||||||
startedTime.className = "opportunities-extension-debug-time";
|
startedTime.className = "opportunities-extension-debug-time";
|
||||||
startedTime.textContent = formatDebugTime(entry.startedAt);
|
startedTime.textContent = formatDebugTime(entry.startedAt);
|
||||||
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
|
startedTime.title = `Started: ${entry.startedAt.toLocaleString("en-GB")}`;
|
||||||
const method = document.createElement("span");
|
const method = document.createElement("span");
|
||||||
method.className = "opportunities-extension-debug-method";
|
method.className = "opportunities-extension-debug-method";
|
||||||
method.textContent = entry.method || "GET";
|
method.textContent = entry.method || "GET";
|
||||||
@@ -228,7 +228,7 @@ function createDebugContentView({
|
|||||||
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
|
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
|
||||||
duration.textContent = hasDuration
|
duration.textContent = hasDuration
|
||||||
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
|
? 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";
|
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
|
||||||
summaryMeta.append(startedTime, method, httpStatus, status, duration);
|
summaryMeta.append(startedTime, method, httpStatus, status, duration);
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ function createDebugContentView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDebugTime(date) {
|
function formatDebugTime(date) {
|
||||||
return date.toLocaleTimeString(undefined, {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
|
|||||||
@@ -78,3 +78,5 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@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-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; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
|
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -33,7 +33,8 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
}
|
}
|
||||||
return String(value).trim();
|
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");
|
const table = element("table", "opportunities-extension-install-base-table");
|
||||||
table.setAttribute("aria-label", "Installed base items");
|
table.setAttribute("aria-label", "Installed base items");
|
||||||
const head = element("thead");
|
const head = element("thead");
|
||||||
@@ -43,13 +44,14 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
const col = element("col");
|
const col = element("col");
|
||||||
col.className = `install-base-col-${key}`;
|
col.className = `install-base-col-${key}`;
|
||||||
group.append(col);
|
group.append(col);
|
||||||
const th = element("th", "", label);
|
const th = element("th");
|
||||||
th.scope = "col";
|
th.scope = "col";
|
||||||
|
sorting.header(th, key, label, onSort, loading);
|
||||||
header.append(th);
|
header.append(th);
|
||||||
});
|
});
|
||||||
head.append(header);
|
head.append(header);
|
||||||
const body = element("tbody");
|
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");
|
const row = element("tr");
|
||||||
columns.forEach(([key, , type]) => {
|
columns.forEach(([key, , type]) => {
|
||||||
const cell = element("td");
|
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...");
|
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
|
||||||
status.setAttribute("role", "status");
|
status.setAttribute("role", "status");
|
||||||
let loadedItems = [], loaded = false, failure = "";
|
let loadedItems = [], loaded = false, failure = "";
|
||||||
|
const sorting = tableSorting.create("installed-base", sortColumns);
|
||||||
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
|
const search = createSearch({ label: "Search installed base", onSearch: renderResults,
|
||||||
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
|
getValues: item => columns.flatMap(([key, , type]) => [item[key], formatValue(item[key], type)]) });
|
||||||
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
||||||
@@ -115,7 +118,7 @@ function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponse
|
|||||||
modal.addEventListener("keydown", event => {
|
modal.addEventListener("keydown", event => {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
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);
|
const index = controls.indexOf(document.activeElement);
|
||||||
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
|
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.");
|
: "No installed base items found for this customer.");
|
||||||
status.setAttribute("role", failure ? "alert" : "status");
|
status.setAttribute("role", failure ? "alert" : "status");
|
||||||
content.append(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;
|
content.scrollTop = 0;
|
||||||
}
|
}
|
||||||
return { render(items, error = "") {
|
return { render(items, error = "") {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
.oe-time-grid { overflow-x: auto; }
|
.oe-time-grid { overflow-x: auto; }
|
||||||
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
|
.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 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: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-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
|
||||||
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
|
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
|
||||||
|
|||||||
@@ -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;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let ready = false;
|
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 }; }
|
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
|
||||||
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
|
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) {
|
function rowPayload(row) {
|
||||||
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
|
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
|
||||||
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
|
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;
|
const focusedId = document.activeElement?.id;
|
||||||
combos = [];
|
combos = [];
|
||||||
entry.tbody.replaceChildren();
|
entry.tbody.replaceChildren();
|
||||||
entry.rows.forEach((row, index) => {
|
sorting.select(entry.rows).forEach(row => {
|
||||||
|
const index = entry.rows.indexOf(row);
|
||||||
const tr = node("tr");
|
const tr = node("tr");
|
||||||
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
|
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
|
||||||
tr.className = "oe-time-duplicate-row";
|
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")));
|
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);
|
entry.rows = entry.drafts.get(key);
|
||||||
const head = node("thead"), row = node("tr");
|
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 = [];
|
entry.dayTotals = [];
|
||||||
for (const day of items) {
|
for (const day of items) {
|
||||||
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
|
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);
|
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"));
|
[...row.children].forEach(th => th.setAttribute("scope", "col"));
|
||||||
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
|
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();
|
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
const count = dayCounts.get(key) || 0;
|
const count = dayCounts.get(key) || 0;
|
||||||
badge.hidden = count === 0;
|
badge.hidden = count === 0;
|
||||||
badge.textContent = String(count);
|
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("aria-label", badge.title);
|
||||||
badge.setAttribute("data-empty", String(count === 0));
|
badge.setAttribute("data-empty", String(count === 0));
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
if (copy) identity.append(copy);
|
if (copy) identity.append(copy);
|
||||||
const summary = node("dl", "opportunities-extension-action-form-context");
|
const summary = node("dl", "opportunities-extension-action-form-context");
|
||||||
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
|
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 || "-")));
|
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
|
||||||
}
|
}
|
||||||
const badges = node("div", "opportunities-calendar-detail-badges");
|
const badges = node("div", "opportunities-calendar-detail-badges");
|
||||||
@@ -154,13 +154,13 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
for (const event of events.values()) {
|
for (const event of events.values()) {
|
||||||
const item = event.extendedProps.item;
|
const item = event.extendedProps.item;
|
||||||
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
|
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.append(node("strong", "", event.title), node("span", "", summary));
|
||||||
button.title = `${event.title}\n${summary}`;
|
button.title = `${event.title}\n${summary}`;
|
||||||
button.addEventListener("click", () => showDetails(item, trigger));
|
button.addEventListener("click", () => showDetails(item, trigger));
|
||||||
controls.push(button); content.append(button);
|
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.
|
// A truthy non-string result prevents FullCalendar from opening its measured popover.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,14 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
closeDetails(false);
|
closeDetails(false);
|
||||||
const workspace = node("section", "opportunities-extension-views");
|
const workspace = node("section", "opportunities-extension-views");
|
||||||
const tablist = node("div", "opportunities-extension-view-tabs");
|
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 tabs = {};
|
||||||
const surface = node("section", "opportunities-extension-calendar");
|
const surface = node("section", "opportunities-extension-calendar");
|
||||||
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
|
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
|
||||||
const calendarHost = node("div", "opportunities-calendar-grid");
|
const calendarHost = node("div", "opportunities-calendar-grid");
|
||||||
const undated = node("details", "opportunities-calendar-undated");
|
const undated = node("details", "opportunities-calendar-undated");
|
||||||
surface.append(message, calendarHost, 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`;
|
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
|
||||||
tab.append(createViewIcon(key), node("span", "", label));
|
tab.append(createViewIcon(key), node("span", "", label));
|
||||||
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
|
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, {
|
calendar = new library.Calendar(calendarHost, {
|
||||||
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
|
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
|
||||||
datesSet: scheduleLayout,
|
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" },
|
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
|
||||||
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
|
buttonText: { today: "Today", month: "Month", week: "Week" },
|
||||||
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
|
buttonHints: { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" },
|
||||||
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
|
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
|
||||||
moreLinkHint: count => `Ver mais ${count} oportunidades`,
|
moreLinkHint: count => `View ${count} more ${count === 1 ? "opportunity" : "opportunities"}`,
|
||||||
moreLinkClick: showDayOpportunities,
|
moreLinkClick: showDayOpportunities,
|
||||||
weekNumbers: true,
|
weekNumbers: true,
|
||||||
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
|
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
|
||||||
@@ -255,7 +255,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
eventContent: ({ event }) => {
|
eventContent: ({ event }) => {
|
||||||
const item = event.extendedProps.item;
|
const item = event.extendedProps.item;
|
||||||
const card = node("div", "opportunities-calendar-event-content");
|
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;
|
summary.title = summary.textContent;
|
||||||
card.append(node("strong", "", event.title), summary);
|
card.append(node("strong", "", event.title), summary);
|
||||||
return { domNodes: [card] };
|
return { domNodes: [card] };
|
||||||
@@ -280,7 +280,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
|
|||||||
const missing = items.filter(item => !closeDate(item.EffectiveDate));
|
const missing = items.filter(item => !closeDate(item.EffectiveDate));
|
||||||
undated.hidden = !missing.length; undated.textContent = "";
|
undated.hidden = !missing.length; undated.textContent = "";
|
||||||
if (missing.length) {
|
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) {
|
for (const item of missing) {
|
||||||
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
|
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
|
||||||
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
|
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
|
||||||
|
|||||||
@@ -72,15 +72,15 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
|||||||
const summary = getSummary(optyNumber);
|
const summary = getSummary(optyNumber);
|
||||||
const reportedHours = summary.reportedHours !== null
|
const reportedHours = summary.reportedHours !== null
|
||||||
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
|
||||||
: "N/D";
|
: "N/A";
|
||||||
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
|
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/A" ? "" : "h"}` : "0h");
|
||||||
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
|
const display = value => value === null || value === undefined || value === "" ? "N/A" : String(value);
|
||||||
const descriptions = items.map(item => [
|
const descriptions = items.map(item => [
|
||||||
`Title: ${display(item.Title)}`,
|
`Title: ${display(item.Title)}`,
|
||||||
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
|
||||||
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
`Reported Hours: ${display(item.ReportedHours_c)}`,
|
||||||
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_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)}`,
|
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
|
||||||
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
|
||||||
].join("\n"));
|
].join("\n"));
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
heading.className = "opportunities-extension-sr-number";
|
heading.className = "opportunities-extension-sr-number";
|
||||||
const number = item.SrNumber;
|
const number = item.SrNumber;
|
||||||
const link = document.createElement(number ? "a" : "span");
|
const link = document.createElement(number ? "a" : "span");
|
||||||
link.textContent = number ? String(number) : "N/D";
|
link.textContent = number ? String(number) : "N/A";
|
||||||
if (number) {
|
if (number) {
|
||||||
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
|
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(number))}`;
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
@@ -137,7 +137,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = "Internal SR Number:";
|
label.textContent = "Internal SR Number:";
|
||||||
const link = document.createElement(record.SrNumber ? "a" : "span");
|
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) {
|
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.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";
|
link.target = "_blank";
|
||||||
@@ -149,7 +149,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
|
|||||||
body.className = "opportunities-extension-sr-details";
|
body.className = "opportunities-extension-sr-details";
|
||||||
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
|
||||||
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
|
"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);
|
child.append(row, body);
|
||||||
children.append(child);
|
children.append(child);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function createOpportunitiesDashboard({
|
|||||||
totalValue,
|
totalValue,
|
||||||
"TOTAL",
|
"TOTAL",
|
||||||
items.length,
|
items.length,
|
||||||
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
|
(amount) => `${Math.round(amount).toLocaleString("en-US")}/${totalCount.toLocaleString("en-US")}`
|
||||||
);
|
);
|
||||||
totalCard.append(totalLabel, totalValue);
|
totalCard.append(totalLabel, totalValue);
|
||||||
dashboard.append(totalCard);
|
dashboard.append(totalCard);
|
||||||
@@ -81,7 +81,7 @@ function createOpportunitiesDashboard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (amount) => amount.toLocaleString(undefined, {
|
return (amount) => amount.toLocaleString("en-US", {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function createOpportunitiesFormatters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
return number.toLocaleString();
|
return number.toLocaleString("en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +153,7 @@ function createOpportunitiesFormatters({
|
|||||||
currency
|
currency
|
||||||
}).format(number);
|
}).format(number);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `${currency} ${number.toLocaleString()}`;
|
return `${currency} ${number.toLocaleString("en-US")}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
|
|||||||
const context = node("dl", "opportunities-extension-action-form-context");
|
const context = node("dl", "opportunities-extension-action-form-context");
|
||||||
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
|
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
|
||||||
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
|
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);
|
heading.append(title, context);
|
||||||
header.append(heading);
|
header.append(heading);
|
||||||
@@ -60,7 +60,7 @@ function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOppor
|
|||||||
const titleInput = field("title", "Title", "text", true).input;
|
const titleInput = field("title", "Title", "text", true).input;
|
||||||
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
|
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
|
||||||
deadline.readOnly = true;
|
deadline.readOnly = true;
|
||||||
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
|
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/A";
|
||||||
field("internalDeadline", "Internal Deadline", "date");
|
field("internalDeadline", "Internal Deadline", "date");
|
||||||
const platform = field("platform", "Platform", "select", true);
|
const platform = field("platform", "Platform", "select", true);
|
||||||
field("description", "Description", "textarea", true);
|
field("description", "Description", "textarea", true);
|
||||||
|
|||||||
@@ -412,6 +412,14 @@
|
|||||||
table-layout: fixed;
|
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. */
|
/* 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="name"] { width: auto; }
|
||||||
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
|
.opportunities-extension-table > thead > tr > th[data-column="customer"] { width: auto; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
function createOpportunitiesTable({
|
function createOpportunitiesTable({
|
||||||
|
tableSorting,
|
||||||
ACCOUNT_DETAIL_URL,
|
ACCOUNT_DETAIL_URL,
|
||||||
OPPORTUNITIES_COLUMNS,
|
OPPORTUNITIES_COLUMNS,
|
||||||
OPPORTUNITIES_TABLE_ID,
|
OPPORTUNITIES_TABLE_ID,
|
||||||
@@ -41,8 +42,8 @@ function createOpportunitiesTable({
|
|||||||
opportunitiesTableState.opportunitiesTableState = {
|
opportunitiesTableState.opportunitiesTableState = {
|
||||||
...opportunitiesTableState.opportunitiesTableState,
|
...opportunitiesTableState.opportunitiesTableState,
|
||||||
...nextState,
|
...nextState,
|
||||||
sortKey: nextState.items ? "" : opportunitiesTableState.opportunitiesTableState.sortKey,
|
sortKey: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).key,
|
||||||
sortDirection: nextState.items ? "ascending" : opportunitiesTableState.opportunitiesTableState.sortDirection
|
sortDirection: tableSorting.read("overview", OPPORTUNITIES_COLUMNS).direction
|
||||||
};
|
};
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
renderCustomerFilter();
|
renderCustomerFilter();
|
||||||
@@ -64,7 +65,7 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const caption = document.createElement("caption");
|
const caption = document.createElement("caption");
|
||||||
caption.className = "opportunities-extension-visually-hidden";
|
caption.className = "opportunities-extension-visually-hidden";
|
||||||
caption.textContent = "Opportunities";
|
caption.textContent = opportunitiesTableState.opportunitiesTableState.status === "loading" ? "Loading opportunities..." : "Opportunities";
|
||||||
|
|
||||||
const tableHead = document.createElement("thead");
|
const tableHead = document.createElement("thead");
|
||||||
const headerRow = document.createElement("tr");
|
const headerRow = document.createElement("tr");
|
||||||
@@ -85,6 +86,7 @@ function createOpportunitiesTable({
|
|||||||
|
|
||||||
const sortButton = document.createElement("button");
|
const sortButton = document.createElement("button");
|
||||||
sortButton.type = "button";
|
sortButton.type = "button";
|
||||||
|
sortButton.disabled = opportunitiesTableState.opportunitiesTableState.status === "loading";
|
||||||
sortButton.className = "opportunities-extension-sort-button";
|
sortButton.className = "opportunities-extension-sort-button";
|
||||||
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
sortButton.setAttribute("data-sort-direction", isSorted ? opportunitiesTableState.opportunitiesTableState.sortDirection : "none");
|
||||||
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
sortButton.setAttribute("aria-label", `Sort by ${column.label}${isSorted ? `, ${opportunitiesTableState.opportunitiesTableState.sortDirection}` : ""}`);
|
||||||
@@ -106,7 +108,32 @@ function createOpportunitiesTable({
|
|||||||
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
|
||||||
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
|
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 row = document.createElement("tr");
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
cell.className = "opportunities-extension-table-status";
|
cell.className = "opportunities-extension-table-status";
|
||||||
@@ -193,11 +220,13 @@ function createOpportunitiesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sortOpportunitiesBy(key) {
|
function sortOpportunitiesBy(key) {
|
||||||
|
if (!OPPORTUNITIES_COLUMNS.some(column => column.key === key && column.sortable !== false)) return;
|
||||||
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
const isSameColumn = opportunitiesTableState.opportunitiesTableState.sortKey === key;
|
||||||
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
opportunitiesTableState.opportunitiesTableState.sortKey = key;
|
||||||
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
opportunitiesTableState.opportunitiesTableState.sortDirection = isSameColumn && opportunitiesTableState.opportunitiesTableState.sortDirection === "ascending"
|
||||||
? "descending"
|
? "descending"
|
||||||
: "ascending";
|
: "ascending";
|
||||||
|
tableSorting.save("overview", { key, direction: opportunitiesTableState.opportunitiesTableState.sortDirection });
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
|||||||
badge.setAttribute("data-state", state);
|
badge.setAttribute("data-state", state);
|
||||||
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
|
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
|
||||||
badge.title = actions.length ? actions.map(action => [
|
badge.title = actions.length ? actions.map(action => [
|
||||||
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
|
`Action: ${action.name ?? "N/A"}`, `ID: ${action.id ?? "N/A"}`, `Status: ${action.status ?? "N/A"}`,
|
||||||
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
|
`Owner: ${action.owner || "N/A"}`, `Email: ${action.ownerEmail || "N/A"}`, `Workload: ${action.workloadName || "N/A"}`,
|
||||||
`Description: ${action.workloadDescription || "N/D"}`, `Start: ${action.startDate || "N/D"}`, `End: ${action.endDate || "N/D"}`
|
`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."
|
].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.";
|
: 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.";
|
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
|
||||||
|
|||||||
75
src/features/preferences/table-sorting.js
Normal file
75
src/features/preferences/table-sorting.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ function createPreferencesView({
|
|||||||
const closeButton = document.createElement("button");
|
const closeButton = document.createElement("button");
|
||||||
closeButton.type = "button";
|
closeButton.type = "button";
|
||||||
closeButton.className = "opportunities-extension-icon-button";
|
closeButton.className = "opportunities-extension-icon-button";
|
||||||
closeButton.setAttribute("aria-label", "Fechar preferências");
|
closeButton.setAttribute("aria-label", "Close preferences");
|
||||||
closeButton.title = "Close";
|
closeButton.title = "Close";
|
||||||
closeButton.append(createCloseIcon());
|
closeButton.append(createCloseIcon());
|
||||||
closeButton.addEventListener("click", closePreferencesModal);
|
closeButton.addEventListener("click", closePreferencesModal);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function createProductsContentJobs({
|
|||||||
timeStyle: "short"
|
timeStyle: "short"
|
||||||
}).format(new Date(timestamp));
|
}).format(new Date(timestamp));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Date(timestamp).toLocaleString();
|
return new Date(timestamp).toLocaleString("en-GB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ function createProductsDetails({
|
|||||||
function createProductsRampBadge(productsResult) {
|
function createProductsRampBadge(productsResult) {
|
||||||
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
|
const values = (Array.isArray(productsResult.items) ? productsResult.items : [])
|
||||||
.map(item => item && item.RampMonths_c)
|
.map(item => item && item.RampMonths_c)
|
||||||
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/D" : String(value).trim())
|
.map(value => value === null || value === undefined || String(value).trim() === "" ? "N/A" : String(value).trim())
|
||||||
.filter((value, index, items) => value !== "N/D" || items.indexOf(value) === index);
|
.filter((value, index, items) => value !== "N/A" || items.indexOf(value) === index);
|
||||||
if (!values.length) return null;
|
if (!values.length) return null;
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "opportunities-extension-products-ramp";
|
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(" | "));
|
setBadgeContent(badge, "ramp", values.join(" | "));
|
||||||
badge.title = `Ramp Months: ${values.join(", ")}`;
|
badge.title = `Ramp Months: ${values.join(", ")}`;
|
||||||
badge.setAttribute("aria-label", badge.title);
|
badge.setAttribute("aria-label", badge.title);
|
||||||
@@ -114,7 +114,7 @@ function createProductsDetails({
|
|||||||
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
{ label: "Close date", value: (item) => formatOracleResponseDate(item.EffectiveDate) || "-" },
|
||||||
{ label: "Consumption Start", value: (item) => formatOracleResponseDate(item.ConsumptionStartDate_c) || "-" },
|
{ 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() === ""
|
{ 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) }
|
{ label: "Projection", render: (item) => createProjectionIndicator(item, opportunity) }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
function node(tag, className, text) {
|
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 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 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");
|
const head = node("thead"), headRow = node("tr"), tbody = node("tbody");
|
||||||
for (const label of ["Month", "Forecast", "Calculated", "Difference"]) {
|
const sorting = tableSorting.create("consumption-projection", [
|
||||||
const th = node("th", "", label); th.scope = "col"; headRow.append(th);
|
{ 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);
|
head.append(headRow);
|
||||||
for (const item of result.rows) {
|
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)"));
|
if (item.prorated) month.append(node("span", "opportunities-extension-projection-prorated", " (Prorated)"));
|
||||||
tr.append(month);
|
tr.append(month);
|
||||||
for (const value of [item.actual, item.calculated, item.difference]) tr.append(node("td", "", value === null ? "-" : formatAmount(value, currency)));
|
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);
|
table.append(caption, head, tbody);
|
||||||
viewport.append(table);
|
viewport.append(table);
|
||||||
body.append(viewport);
|
body.append(viewport);
|
||||||
@@ -203,7 +211,7 @@ function createRampProjectionView({ MODAL_ID, compare, createAlertIcon, createCl
|
|||||||
modal.addEventListener("keydown", event => {
|
modal.addEventListener("keydown", event => {
|
||||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||||
if (event.key === "Tab") {
|
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);
|
const index = controls.indexOf(document.activeElement);
|
||||||
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
|
||||||
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
|
||||||
|
|||||||
@@ -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, .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 th:first-child { text-align: left; }
|
||||||
.opportunities-extension-projection-table thead th { background: #f5f4f2; font-weight: 600; }
|
.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 tbody th { font-weight: 400; }
|
||||||
.opportunities-extension-projection-table tr[data-divergent="true"] { background: #fff7e5; }
|
.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; }
|
.opportunities-extension-projection-table tr[data-divergent="true"] td:last-child { color: #815400; font-weight: 700; }
|
||||||
|
|||||||
@@ -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;
|
const { document, setTimeout, clearTimeout, requestAnimationFrame, cancelAnimationFrame } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let closing = null;
|
let closing = null;
|
||||||
@@ -78,6 +78,9 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
const headRow = element("tr");
|
const headRow = element("tr");
|
||||||
const sortHeaders = [];
|
const sortHeaders = [];
|
||||||
const current = { root, background, wasInert: background.inert, trigger, items: [], status: "loading", sortKey: "", direction: "asc", openFrame: null };
|
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;
|
active = current;
|
||||||
background.inert = true;
|
background.inert = true;
|
||||||
const alive = () => active === current && root.isConnected && !parent.hidden;
|
const alive = () => active === current && root.isConnected && !parent.hidden;
|
||||||
@@ -98,6 +101,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
sort.addEventListener("click", () => {
|
sort.addEventListener("click", () => {
|
||||||
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
|
current.direction = current.sortKey === column.key && current.direction === "asc" ? "desc" : "asc";
|
||||||
current.sortKey = column.key;
|
current.sortKey = column.key;
|
||||||
|
tableSorting.save("rate-card", { key: current.sortKey, direction: current.direction === "desc" ? "descending" : "ascending" });
|
||||||
updateSortHeaders();
|
updateSortHeaders();
|
||||||
if (current.status === "ready") renderRows();
|
if (current.status === "ready") renderRows();
|
||||||
});
|
});
|
||||||
@@ -106,6 +110,7 @@ function createRatecardView({ ACCOUNT_PLANS_MODAL_ID, columns, formatValue, sele
|
|||||||
headRow.append(th);
|
headRow.append(th);
|
||||||
});
|
});
|
||||||
head.append(headRow);
|
head.append(headRow);
|
||||||
|
updateSortHeaders();
|
||||||
const body = element("tbody");
|
const body = element("tbody");
|
||||||
table.append(caption, colgroup, head, body);
|
table.append(caption, colgroup, head, body);
|
||||||
viewport.append(table);
|
viewport.append(table);
|
||||||
|
|||||||
@@ -38,3 +38,5 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@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-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; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, environment }) {
|
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, createSearch, tableSorting, environment }) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -31,7 +31,8 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
|
|||||||
const text = value === null || value === undefined || value === "" ? "-" : String(value);
|
const text = value === null || value === undefined || value === "" ? "-" : String(value);
|
||||||
return text !== "-" && (key === "created" || key === "lastUpdated") ? formatOracleResponseDate(value) : text;
|
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");
|
const table = element("table", "opportunities-extension-service-requests-table");
|
||||||
table.setAttribute("aria-label", "Customer service requests");
|
table.setAttribute("aria-label", "Customer service requests");
|
||||||
const group = element("colgroup");
|
const group = element("colgroup");
|
||||||
@@ -39,13 +40,14 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
|
|||||||
const header = element("tr");
|
const header = element("tr");
|
||||||
columns.forEach(([key, label]) => {
|
columns.forEach(([key, label]) => {
|
||||||
group.append(element("col", `service-requests-col-${key}`));
|
group.append(element("col", `service-requests-col-${key}`));
|
||||||
const th = element("th", "", label);
|
const th = element("th");
|
||||||
th.scope = "col";
|
th.scope = "col";
|
||||||
|
sorting.header(th, key, label, onSort, loading);
|
||||||
header.append(th);
|
header.append(th);
|
||||||
});
|
});
|
||||||
head.append(header);
|
head.append(header);
|
||||||
const body = element("tbody");
|
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");
|
const row = element("tr");
|
||||||
columns.forEach(([key, , getValue]) => {
|
columns.forEach(([key, , getValue]) => {
|
||||||
const cell = element("td");
|
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...");
|
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
|
||||||
status.setAttribute("role", "status");
|
status.setAttribute("role", "status");
|
||||||
let loadedItems = [], loaded = false, failure = "";
|
let loadedItems = [], loaded = false, failure = "";
|
||||||
|
const sorting = tableSorting.create("service-requests", sortColumns);
|
||||||
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
|
const search = createSearch({ label: "Search service requests", onSearch: renderResults,
|
||||||
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
|
getValues: item => columns.flatMap(([key, , getValue]) => [getValue(item), formatValue(getValue(item), key)]) });
|
||||||
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
const toolbar = element("div", "opportunities-extension-customer-table-toolbar");
|
||||||
@@ -111,7 +114,7 @@ function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResp
|
|||||||
modal.addEventListener("keydown", event => {
|
modal.addEventListener("keydown", event => {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
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);
|
const index = controls.indexOf(document.activeElement);
|
||||||
controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length].focus();
|
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.");
|
: "No open service requests found for this customer.");
|
||||||
status.setAttribute("role", failure ? "alert" : "status");
|
status.setAttribute("role", failure ? "alert" : "status");
|
||||||
content.append(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;
|
content.scrollTop = 0;
|
||||||
}
|
}
|
||||||
return { render(items, error = "") {
|
return { render(items, error = "") {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
|
|||||||
checkIcon(ramp, "ramp", "1 | 2 | 3");
|
checkIcon(ramp, "ramp", "1 | 2 | 3");
|
||||||
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
|
||||||
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
|
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");
|
assert.equal(missingRamp.attributes["data-missing"], "true");
|
||||||
|
|
||||||
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
|
|||||||
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
|
||||||
|
|
||||||
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
|
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 }));
|
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);
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
|
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 () => {
|
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 }));
|
const app = setup(async () => response({ items: [{ ...sr("A"), CustomerDeadline_c: value }], hasMore: false }));
|
||||||
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
await app.opportunityCrmServiceRequests.load("synthetic", 1);
|
||||||
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));
|
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test("request summaries show duration after status, including zero, failure and
|
|||||||
const h = fixture();
|
const h = fixture();
|
||||||
h.add("duration", "requestOpportunities", 1);
|
h.add("duration", "requestOpportunities", 1);
|
||||||
const entry = h.app.debugState.requestLog.at(-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 });
|
Object.assign(entry, { durationMs, status });
|
||||||
h.app.debugContentView.renderDebugPanel();
|
h.app.debugContentView.renderDebugPanel();
|
||||||
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
|
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ class Element {
|
|||||||
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
|
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); }
|
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
|
||||||
set textContent(value) { this.text = value; this.children = []; }
|
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; }
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
addEventListener(key, fn) { this.listeners[key] = fn; }
|
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||||
focus() { this.document.activeElement = this; }
|
focus() { this.document.activeElement = this; }
|
||||||
@@ -100,13 +102,28 @@ class Element {
|
|||||||
get isConnected() { return Boolean(this.parent); }
|
get isConnected() { return Boolean(this.parent); }
|
||||||
click() { return this.listeners.click(); }
|
click() { return this.listeners.click(); }
|
||||||
}
|
}
|
||||||
function setup() {
|
function setup(localStorage) {
|
||||||
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
|
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
|
||||||
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
|
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
|
||||||
const overlay = new Element("main", document);
|
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 };
|
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 customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
|
||||||
const walk = node => [node, ...node.children.flatMap(walk)];
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
|
|||||||
const calls = [], notifications = [];
|
const calls = [], notifications = [];
|
||||||
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
|
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,
|
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 }),
|
notify: (message, isError = false) => notifications.push({ message, isError }),
|
||||||
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
|
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
|
||||||
app.setReady(true);
|
app.setReady(true);
|
||||||
@@ -64,6 +65,27 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
|
|||||||
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
|
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
|
||||||
return { app, calls, nodes, find, host, notifications };
|
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 () => {
|
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
|
||||||
const deleted = [];
|
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));
|
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));
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
|
|||||||
return h;
|
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", () => {
|
test("close dates retain the API day across offsets and reject invalid dates", () => {
|
||||||
const { api } = setup();
|
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");
|
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];
|
const badge = frame.children[0];
|
||||||
assert.equal(badge.textContent, "3");
|
assert.equal(badge.textContent, "3");
|
||||||
assert.equal(badge.hidden, false);
|
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);
|
h.api.render([h.state.items[0]], h.state);
|
||||||
assert.equal(badge.textContent, "1");
|
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);
|
h.api.render([], h.state);
|
||||||
assert.equal(badge.textContent, "0");
|
assert.equal(badge.textContent, "0");
|
||||||
assert.equal(badge.attributes["data-empty"], "true");
|
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);
|
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");
|
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[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");
|
assert.equal(tabs[0].attributes["aria-selected"], "true");
|
||||||
tabs[1].click();
|
tabs[1].click();
|
||||||
const instance = h.instances[0];
|
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.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.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.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.api.closeDetails(), true);
|
||||||
assert.equal(h.document.activeElement, undated.children[1]);
|
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", () => {
|
test("calendar events show their stage and handle missing stage values", () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
const calendar = h.activate();
|
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 item = { ...h.state.items[0], ForecastGroup_c: value };
|
||||||
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
|
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
|
||||||
assert.equal(domNodes[0].children.length, 2);
|
assert.equal(domNodes[0].children.length, 2);
|
||||||
|
|||||||
@@ -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"
|
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 table = new Element("table");
|
||||||
const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
|
const document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
|
||||||
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
|
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;
|
app.currentUser.allowsCrmActivities = () => true;
|
||||||
const summaries = [];
|
const summaries = [];
|
||||||
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
|
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
|
||||||
@@ -46,6 +46,22 @@ function setup(items = [synthetic()]) {
|
|||||||
return { app, table, rows, cell, headers, summaries };
|
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", () => {
|
test("ten columns retain their links and put product/customer controls below the corresponding text", () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ ProdGroupName: "Cloud" }] });
|
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");
|
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
|
||||||
assert.equal(badge(), undefined);
|
assert.equal(badge(), undefined);
|
||||||
for (const [values, expected] of [
|
for (const [values, expected] of [
|
||||||
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
|
[[], null], [[null], "N/A"], [[null, undefined, "", " "], "N/A"], [[6], "6"],
|
||||||
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/D | 6 | 3"]
|
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
|
||||||
]) {
|
]) {
|
||||||
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
|
||||||
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
|
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);
|
if (expected === null) assert.equal(badge(), undefined);
|
||||||
else {
|
else {
|
||||||
assert.equal(badge().textContent, expected);
|
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().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
|
||||||
assert.equal(badge().attributes["aria-label"], badge().title);
|
assert.equal(badge().attributes["aria-label"], badge().title);
|
||||||
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
|
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();
|
findClass(h.cell("name"), "opportunities-extension-products-count").click();
|
||||||
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
|
const table = findClass(h.rows()[1], "opportunities-extension-products-table");
|
||||||
const rows = table.children.find(child => child.tag === "tbody").children;
|
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 () => {
|
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, "-");
|
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();
|
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" });
|
h.app.opportunitiesTable.setOpportunitiesTableState({ status, message: "Synthetic state" });
|
||||||
assert.equal(h.rows().length, 1);
|
assert.equal(h.rows().length, 1);
|
||||||
assert.equal(h.rows()[0].children[0].colSpan, 10);
|
assert.equal(h.rows()[0].children[0].colSpan, 10);
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ function dom() {
|
|||||||
set textContent(value) { this.text = String(value); this.children = []; }
|
set textContent(value) { this.text = String(value); this.children = []; }
|
||||||
get textContent() { return (this.text || "") + this.children.map(child => child.textContent).join(""); }
|
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); } }
|
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; }
|
setAttribute(name, value) { this.attributes[name] = value; }
|
||||||
addEventListener(name, fn) { this.listeners[name] = fn; }
|
addEventListener(name, fn) { this.listeners[name] = fn; }
|
||||||
focus() { document.activeElement = this; }
|
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);
|
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", () => {
|
test("product column opens accessible read-only comparison and restores focus/background on close", () => {
|
||||||
const { document, host, content } = dom();
|
const { document, host, content } = dom();
|
||||||
const app = loadApplication("content", { document, Intl, Date });
|
const app = loadApplication("content", { document, Intl, Date });
|
||||||
|
|||||||
@@ -156,6 +156,19 @@ function setup() {
|
|||||||
}
|
}
|
||||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
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 () => {
|
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();
|
const { app, root, buttons, find, document, requests, logs } = setup();
|
||||||
assert.equal(buttons.length, 2);
|
assert.equal(buttons.length, 2);
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ class Element {
|
|||||||
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
|
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); }
|
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
|
||||||
set textContent(value) { this.text = value; this.children = []; }
|
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; }
|
setAttribute(key, value) { this.attributes[key] = value; }
|
||||||
addEventListener(key, fn) { this.listeners[key] = fn; }
|
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||||
focus() { this.document.activeElement = this; }
|
focus() { this.document.activeElement = this; }
|
||||||
@@ -134,13 +136,28 @@ class Element {
|
|||||||
click() { return this.listeners.click(); }
|
click() { return this.listeners.click(); }
|
||||||
}
|
}
|
||||||
const walk = node => [node, ...node.children.flatMap(walk)];
|
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),
|
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
|
||||||
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
|
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
|
||||||
const overlay = new Element("main", document);
|
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 };
|
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" } };
|
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", () => {
|
test("service request search covers nested and formatted columns and preserves errors and full results on clearing", () => {
|
||||||
|
|||||||
44
tests/tableSorting.test.cjs
Normal file
44
tests/tableSorting.test.cjs
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user